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;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { rankMessagesByImportance, scoreMessageImportance, selectImportantMessages } from './weighting.js';
|
||||
import type { Message } from '../models/types.js';
|
||||
|
||||
describe('weighting', () => {
|
||||
it('scores low-value chatter lower than preference and tool outcomes', () => {
|
||||
const chatter: Message = { role: 'user', content: 'hello there' };
|
||||
const preference: Message = { role: 'user', content: 'I prefer concise bullet points in responses.' };
|
||||
const outcome: Message = { role: 'assistant', content: 'Command succeeded with exit code 0. Output saved.' };
|
||||
|
||||
const chatterScore = scoreMessageImportance(chatter);
|
||||
const preferenceScore = scoreMessageImportance(preference);
|
||||
const outcomeScore = scoreMessageImportance(outcome);
|
||||
|
||||
expect(preferenceScore).toBeGreaterThan(chatterScore);
|
||||
expect(outcomeScore).toBeGreaterThan(chatterScore);
|
||||
});
|
||||
|
||||
it('applies recency as a small tie-break boost', () => {
|
||||
const messages: Message[] = [
|
||||
{ role: 'user', content: 'simple note' },
|
||||
{ role: 'user', content: 'simple note' },
|
||||
];
|
||||
|
||||
const ranked = rankMessagesByImportance(messages);
|
||||
expect(ranked[1].score).toBeGreaterThan(ranked[0].score);
|
||||
});
|
||||
|
||||
it('selects important messages above threshold and keeps original order', () => {
|
||||
const messages: Message[] = [
|
||||
{ role: 'user', content: 'hello' },
|
||||
{ role: 'user', content: 'I prefer markdown tables for reports.' },
|
||||
{ role: 'assistant', content: 'Tool result: command failed with exit code 1' },
|
||||
{ role: 'assistant', content: 'ok' },
|
||||
];
|
||||
|
||||
const selected = selectImportantMessages(messages, {
|
||||
threshold: 0.45,
|
||||
maxMessages: 4,
|
||||
});
|
||||
|
||||
expect(selected).toHaveLength(2);
|
||||
expect(selected[0].index).toBe(1);
|
||||
expect(selected[1].index).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Message } from '../models/types.js';
|
||||
import { getMessageText } from '../models/media.js';
|
||||
|
||||
export interface WeightedMessage {
|
||||
index: number;
|
||||
message: Message;
|
||||
score: number;
|
||||
}
|
||||
|
||||
const TOOL_OUTCOME_PATTERN = /(tool|command|exit code|stack trace|traceback|error|failed|succeeded|output|result)/i;
|
||||
const CORRECTION_PATTERN = /(actually|correction|instead|sorry|to clarify|i meant|wrong)/i;
|
||||
const PREFERENCE_PATTERN = /(prefer|preference|always|never|please|timezone|call me|i like|i dislike|do not|don't)/i;
|
||||
|
||||
export function scoreMessageImportance(message: Message): number {
|
||||
const text = getMessageText(message).trim();
|
||||
if (text.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let score = 0.1;
|
||||
|
||||
if (TOOL_OUTCOME_PATTERN.test(text)) {
|
||||
score += 0.45;
|
||||
}
|
||||
if (CORRECTION_PATTERN.test(text)) {
|
||||
score += 0.35;
|
||||
}
|
||||
if (PREFERENCE_PATTERN.test(text)) {
|
||||
score += 0.4;
|
||||
}
|
||||
if (text.includes('```')) {
|
||||
score += 0.2;
|
||||
}
|
||||
|
||||
if (message.role === 'user') {
|
||||
score += 0.05;
|
||||
}
|
||||
|
||||
if (text.length > 240) {
|
||||
score += 0.1;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(1, score));
|
||||
}
|
||||
|
||||
export function rankMessagesByImportance(messages: Message[]): WeightedMessage[] {
|
||||
return messages.map((message, index) => {
|
||||
const base = scoreMessageImportance(message);
|
||||
const recencyBoost = messages.length > 1 ? (index / (messages.length - 1)) * 0.08 : 0;
|
||||
return {
|
||||
index,
|
||||
message,
|
||||
score: Math.max(0, Math.min(1, base + recencyBoost)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function selectImportantMessages(messages: Message[], opts: {
|
||||
threshold: number;
|
||||
maxMessages: number;
|
||||
}): WeightedMessage[] {
|
||||
if (messages.length === 0 || opts.maxMessages <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ranked = rankMessagesByImportance(messages)
|
||||
.filter(item => item.score >= opts.threshold)
|
||||
.sort((a, b) => b.score - a.score || b.index - a.index)
|
||||
.slice(0, opts.maxMessages)
|
||||
.sort((a, b) => a.index - b.index);
|
||||
|
||||
return ranked;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { GatewayEvent, GatewayRequest, OutboundMessage } from '../protocol.js';
|
||||
import { LaneQueue } from '../lane-queue.js';
|
||||
import { createAgentHandlers } from './agent.js';
|
||||
import { CommandRegistry, registerBuiltinCommands } from '../../commands/index.js';
|
||||
|
||||
describe('createAgentHandlers command fast-path', () => {
|
||||
const mockAgent = {
|
||||
process: vi.fn(async () => 'agent response'),
|
||||
getUsage: vi.fn(() => ({
|
||||
primary: { inputTokens: 10, outputTokens: 5, calls: 1 },
|
||||
delegation: {},
|
||||
total: { inputTokens: 10, outputTokens: 5, calls: 1, estimatedCost: 0 },
|
||||
})),
|
||||
getModelTier: vi.fn(() => 'default'),
|
||||
setModelTier: vi.fn(),
|
||||
compact: vi.fn(async () => null),
|
||||
reset: vi.fn(),
|
||||
};
|
||||
|
||||
const sessionBridge = {
|
||||
getAgent: vi.fn(() => mockAgent),
|
||||
getSessionId: vi.fn(() => 'ws:conn-1'),
|
||||
setBusy: vi.fn(),
|
||||
setOnToolUse: vi.fn(),
|
||||
isBusy: vi.fn(() => false),
|
||||
};
|
||||
|
||||
const sessionManager = {
|
||||
setSessionConfig: vi.fn(),
|
||||
deleteSessionConfig: vi.fn(),
|
||||
};
|
||||
|
||||
const commandRegistry = new CommandRegistry();
|
||||
registerBuiltinCommands(commandRegistry);
|
||||
|
||||
const handlers = createAgentHandlers({
|
||||
sessionBridge: sessionBridge as any,
|
||||
laneQueue: new LaneQueue(),
|
||||
sessionManager: sessionManager as any,
|
||||
commandRegistry,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAgent.process.mockResolvedValue('agent response');
|
||||
mockAgent.compact.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it('handles known commands without calling agent.process', async () => {
|
||||
const sent: OutboundMessage[] = [];
|
||||
const send = vi.fn((msg: OutboundMessage) => sent.push(msg));
|
||||
const req: GatewayRequest = {
|
||||
id: 1,
|
||||
method: 'agent.send',
|
||||
params: { message: '/usage', connectionId: 'conn-1' },
|
||||
};
|
||||
|
||||
await handlers['agent.send'](req, send);
|
||||
|
||||
expect(mockAgent.process).not.toHaveBeenCalled();
|
||||
expect(sent).toHaveLength(1);
|
||||
const event = sent[0] as GatewayEvent;
|
||||
expect(event.event).toBe('done');
|
||||
expect((event.data as { content: string }).content).toContain('Token Usage');
|
||||
});
|
||||
|
||||
it('handles metadata commands via fast-path', async () => {
|
||||
const sent: OutboundMessage[] = [];
|
||||
const send = vi.fn((msg: OutboundMessage) => sent.push(msg));
|
||||
const req: GatewayRequest = {
|
||||
id: 2,
|
||||
method: 'agent.send',
|
||||
params: {
|
||||
message: '/reset',
|
||||
connectionId: 'conn-1',
|
||||
metadata: { isCommand: true, command: 'reset' },
|
||||
},
|
||||
};
|
||||
|
||||
await handlers['agent.send'](req, send);
|
||||
|
||||
expect(mockAgent.reset).toHaveBeenCalledOnce();
|
||||
expect(sessionManager.deleteSessionConfig).toHaveBeenCalledWith('ws', 'ws:conn-1', 'modelTier');
|
||||
expect(mockAgent.process).not.toHaveBeenCalled();
|
||||
expect(((sent[0] as GatewayEvent).data as { content: string }).content).toContain('Session reset.');
|
||||
});
|
||||
|
||||
it('falls through to agent.process for unknown commands', async () => {
|
||||
const sent: OutboundMessage[] = [];
|
||||
const send = vi.fn((msg: OutboundMessage) => sent.push(msg));
|
||||
const req: GatewayRequest = {
|
||||
id: 3,
|
||||
method: 'agent.send',
|
||||
params: { message: '/not-a-real-command', connectionId: 'conn-1' },
|
||||
};
|
||||
|
||||
await handlers['agent.send'](req, send);
|
||||
|
||||
expect(mockAgent.process).toHaveBeenCalledWith('/not-a-real-command', undefined);
|
||||
expect((sent[0] as GatewayEvent).event).toBe('done');
|
||||
expect(((sent[0] as GatewayEvent).data as { content: string }).content).toBe('agent response');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { GatewayRequest, OutboundMessage } from '../protocol.js';
|
||||
import { makeError, makeResponse, ErrorCode } from '../protocol.js';
|
||||
import type { SessionManager } from '../../session/manager.js';
|
||||
|
||||
export interface HistoryHandlerDeps {
|
||||
sessionManager: SessionManager;
|
||||
}
|
||||
|
||||
export function createHistoryHandlers(deps: HistoryHandlerDeps) {
|
||||
return {
|
||||
'history.search': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||
const params = request.params as { query?: string; sessionId?: string; limit?: number } | undefined;
|
||||
if (!params?.query) {
|
||||
return makeError(request.id, ErrorCode.InvalidRequest, 'query is required');
|
||||
}
|
||||
|
||||
const results = deps.sessionManager.searchHistory(params.query, {
|
||||
sessionId: params.sessionId,
|
||||
limit: params.limit,
|
||||
});
|
||||
|
||||
return makeResponse(request.id, { results });
|
||||
},
|
||||
|
||||
'history.reindex': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||
const reindexed = deps.sessionManager.reindexHistory();
|
||||
return makeResponse(request.id, { reindexed });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { GatewayRequest, OutboundMessage } from '../protocol.js';
|
||||
import { makeError, makeResponse, ErrorCode } from '../protocol.js';
|
||||
import type { ComponentRegistry } from '../../intents/index.js';
|
||||
|
||||
export interface IntentHandlerDeps {
|
||||
intentRegistry?: ComponentRegistry;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export function createIntentHandlers(deps: IntentHandlerDeps) {
|
||||
return {
|
||||
'intents.list': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||
const rules = deps.intentRegistry?.list() ?? [];
|
||||
return makeResponse(request.id, {
|
||||
enabled: deps.enabled,
|
||||
rules,
|
||||
});
|
||||
},
|
||||
|
||||
'intents.match': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||
const params = request.params as { input?: string } | undefined;
|
||||
if (!params?.input) {
|
||||
return makeError(request.id, ErrorCode.InvalidRequest, 'input is required');
|
||||
}
|
||||
|
||||
const match = deps.intentRegistry?.match(params.input) ?? null;
|
||||
return makeResponse(request.id, {
|
||||
enabled: deps.enabled,
|
||||
match,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { GatewayRequest, OutboundMessage } from '../protocol.js';
|
||||
import { makeError, makeResponse, ErrorCode } from '../protocol.js';
|
||||
import type { ComponentRegistry } from '../../intents/index.js';
|
||||
import type { RoutingPolicy } from '../../routing/index.js';
|
||||
|
||||
export interface RoutingHandlerDeps {
|
||||
intentRegistry?: ComponentRegistry;
|
||||
routingPolicy?: RoutingPolicy;
|
||||
}
|
||||
|
||||
export function createRoutingHandlers(deps: RoutingHandlerDeps) {
|
||||
return {
|
||||
'routing.decide': async (request: GatewayRequest): Promise<OutboundMessage> => {
|
||||
const params = request.params as { input?: string } | undefined;
|
||||
if (!params?.input) {
|
||||
return makeError(request.id, ErrorCode.InvalidRequest, 'input is required');
|
||||
}
|
||||
|
||||
const match = deps.intentRegistry?.match(params.input) ?? null;
|
||||
const decision = deps.routingPolicy?.decide({ confidence: match?.score ?? null }) ?? {
|
||||
path: 'llm',
|
||||
reason: 'disabled',
|
||||
};
|
||||
|
||||
return makeResponse(request.id, {
|
||||
match,
|
||||
decision,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { ComponentRegistry } from './registry.js';
|
||||
export type { IntentRule, IntentTarget, IntentTargetType, IntentMatch, ComponentRegistryConfig } from './registry.js';
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ComponentRegistry } from './registry.js';
|
||||
|
||||
describe('ComponentRegistry', () => {
|
||||
it('matches exact rules deterministically', () => {
|
||||
const registry = new ComponentRegistry({ matchThreshold: 0.5 });
|
||||
registry.loadRules([
|
||||
{
|
||||
name: 'status-help',
|
||||
patterns: ['status'],
|
||||
target: { type: 'agent', name: 'assistant' },
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const match = registry.match('status');
|
||||
expect(match?.rule.name).toBe('status-help');
|
||||
expect(match?.matchedPattern).toBe('status');
|
||||
expect(match?.score).toBe(1);
|
||||
});
|
||||
|
||||
it('matches wildcard patterns', () => {
|
||||
const registry = new ComponentRegistry({ matchThreshold: 0.5 });
|
||||
registry.register({
|
||||
name: 'deploy-route',
|
||||
patterns: ['deploy *'],
|
||||
target: { type: 'agent', name: 'coder' },
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const match = registry.match('deploy api service');
|
||||
expect(match?.rule.name).toBe('deploy-route');
|
||||
expect(match?.score).toBeGreaterThan(0.5);
|
||||
expect(match?.score).toBeLessThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('resolves ties by priority then specificity', () => {
|
||||
const registry = new ComponentRegistry({ matchThreshold: 0.5 });
|
||||
registry.loadRules([
|
||||
{
|
||||
name: 'low-priority',
|
||||
patterns: ['deploy *'],
|
||||
target: { type: 'agent', name: 'assistant' },
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
name: 'high-priority',
|
||||
patterns: ['deploy *'],
|
||||
target: { type: 'agent', name: 'coder' },
|
||||
priority: 10,
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
|
||||
const match = registry.match('deploy dashboard');
|
||||
expect(match?.rule.name).toBe('high-priority');
|
||||
});
|
||||
|
||||
it('returns null when no rule meets threshold', () => {
|
||||
const registry = new ComponentRegistry({ matchThreshold: 0.95 });
|
||||
registry.register({
|
||||
name: 'weak',
|
||||
patterns: ['deploy *'],
|
||||
target: { type: 'agent', name: 'coder' },
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(registry.match('deploy dashboard')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
export type IntentTargetType = 'agent' | 'skill';
|
||||
|
||||
export interface IntentTarget {
|
||||
type: IntentTargetType;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface IntentRule {
|
||||
name: string;
|
||||
patterns: string[];
|
||||
target: IntentTarget;
|
||||
priority: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface IntentMatch {
|
||||
rule: IntentRule;
|
||||
score: number;
|
||||
matchedPattern: string;
|
||||
}
|
||||
|
||||
export interface ComponentRegistryConfig {
|
||||
matchThreshold: number;
|
||||
}
|
||||
|
||||
export class ComponentRegistry {
|
||||
private readonly matchThreshold: number;
|
||||
private readonly rules: IntentRule[] = [];
|
||||
|
||||
constructor(config: ComponentRegistryConfig) {
|
||||
this.matchThreshold = config.matchThreshold;
|
||||
}
|
||||
|
||||
register(rule: IntentRule): void {
|
||||
this.rules.push(rule);
|
||||
}
|
||||
|
||||
loadRules(rules: IntentRule[]): void {
|
||||
for (const rule of rules) {
|
||||
this.register(rule);
|
||||
}
|
||||
}
|
||||
|
||||
list(): IntentRule[] {
|
||||
return [...this.rules];
|
||||
}
|
||||
|
||||
match(input: string): IntentMatch | null {
|
||||
const normalizedInput = input.trim().toLowerCase();
|
||||
if (!normalizedInput) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let best: { match: IntentMatch; specificity: number } | null = null;
|
||||
|
||||
for (const rule of this.rules) {
|
||||
if (!rule.enabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let bestPatternScore = 0;
|
||||
let bestPattern = '';
|
||||
let bestSpecificity = 0;
|
||||
|
||||
for (const pattern of rule.patterns) {
|
||||
const patternResult = this.scorePattern(pattern, normalizedInput);
|
||||
if (!patternResult) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (patternResult.score > bestPatternScore) {
|
||||
bestPatternScore = patternResult.score;
|
||||
bestPattern = pattern;
|
||||
bestSpecificity = patternResult.specificity;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestPatternScore < this.matchThreshold) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const match: IntentMatch = {
|
||||
rule,
|
||||
score: bestPatternScore,
|
||||
matchedPattern: bestPattern,
|
||||
};
|
||||
|
||||
if (!best) {
|
||||
best = { match, specificity: bestSpecificity };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (match.score > best.match.score) {
|
||||
best = { match, specificity: bestSpecificity };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (match.score === best.match.score) {
|
||||
if (rule.priority > best.match.rule.priority) {
|
||||
best = { match, specificity: bestSpecificity };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (rule.priority === best.match.rule.priority && bestSpecificity > best.specificity) {
|
||||
best = { match, specificity: bestSpecificity };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best?.match ?? null;
|
||||
}
|
||||
|
||||
private scorePattern(pattern: string, normalizedInput: string): { score: number; specificity: number } | null {
|
||||
const normalizedPattern = pattern.trim().toLowerCase();
|
||||
if (!normalizedPattern) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const wildcardCount = (normalizedPattern.match(/\*/g) ?? []).length;
|
||||
const literalLength = normalizedPattern.replace(/\*/g, '').length;
|
||||
const specificity = literalLength / Math.max(normalizedInput.length, 1);
|
||||
|
||||
if (wildcardCount === 0) {
|
||||
if (normalizedInput === normalizedPattern) {
|
||||
return { score: 1, specificity };
|
||||
}
|
||||
if (normalizedInput.includes(normalizedPattern)) {
|
||||
return { score: Math.min(0.9 + specificity * 0.1, 1), specificity };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const escaped = normalizedPattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
||||
const regexPattern = escaped.replace(/\*/g, '.*');
|
||||
const regex = new RegExp(`^${regexPattern}$`);
|
||||
if (!regex.test(normalizedInput)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { score: Math.min(0.8 + specificity * 0.15, 0.99), specificity };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { MemoryStore } from './store.js';
|
||||
import { buildAdaptiveMemoryContext, buildRecentMemoryContext } from './adaptive.js';
|
||||
import type { Message } from '../models/types.js';
|
||||
|
||||
describe('adaptive memory context', () => {
|
||||
let dir: string;
|
||||
let store: MemoryStore;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'flynn-adaptive-memory-test-'));
|
||||
store = new MemoryStore({ dir, maxContextTokens: 4000 });
|
||||
|
||||
store.write('user', 'Preferred language: TypeScript', 'replace');
|
||||
store.writeCategory('user', 'preferences', [
|
||||
'Prefers concise responses and direct code snippets.',
|
||||
'Use markdown tables for summaries.',
|
||||
'Avoid unnecessary setup details.',
|
||||
].join('\n\n'), 'replace');
|
||||
store.writeCategory('global', 'facts', [
|
||||
'Project uses pnpm and Vitest.',
|
||||
'Release workflow runs lint, test, and build.',
|
||||
'Kubernetes cluster is k0s on arm64.',
|
||||
].join('\n\n'), 'replace');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('selects memory snippets relevant to the current message', () => {
|
||||
const recentMessages: Message[] = [
|
||||
{ role: 'user', content: 'Please keep answers concise' },
|
||||
{ role: 'assistant', content: 'Understood' },
|
||||
];
|
||||
|
||||
const context = buildAdaptiveMemoryContext({
|
||||
store,
|
||||
userMessage: 'Can you provide a concise summary in markdown table format?',
|
||||
recentMessages,
|
||||
config: { maxTokens: 200 },
|
||||
});
|
||||
|
||||
expect(context).toContain('User Preferences');
|
||||
expect(context).toContain('markdown tables');
|
||||
expect(context).not.toContain('Kubernetes cluster is k0s on arm64');
|
||||
});
|
||||
|
||||
it('respects token budget clipping', () => {
|
||||
const context = buildAdaptiveMemoryContext({
|
||||
store,
|
||||
userMessage: 'Tell me something about this project',
|
||||
recentMessages: [],
|
||||
config: { maxTokens: 20 },
|
||||
});
|
||||
|
||||
expect(context.length).toBeLessThanOrEqual(80);
|
||||
});
|
||||
|
||||
it('recent strategy keeps the tail of memory context', () => {
|
||||
const recentContext = buildRecentMemoryContext(store, 25);
|
||||
const fullContext = store.getContextForPrompt();
|
||||
|
||||
expect(recentContext.length).toBeLessThanOrEqual(100);
|
||||
expect(fullContext.endsWith(recentContext)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { Message } from '../models/types.js';
|
||||
import type { MemoryStore, PromptMemorySection } from './store.js';
|
||||
|
||||
export interface AdaptiveMemoryConfig {
|
||||
maxTokens: number;
|
||||
maxSnippetsPerSection?: number;
|
||||
}
|
||||
|
||||
interface ScoredSnippet {
|
||||
text: string;
|
||||
score: number;
|
||||
overlapScore: number;
|
||||
}
|
||||
|
||||
const STOPWORDS = new Set([
|
||||
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'has', 'he', 'in', 'is', 'it', 'its',
|
||||
'of', 'on', 'or', 'that', 'the', 'to', 'was', 'were', 'will', 'with', 'you', 'your', 'we', 'this',
|
||||
'they', 'their', 'our', 'but', 'not', 'can', 'just', 'into', 'about', 'after', 'before', 'than', 'then',
|
||||
]);
|
||||
|
||||
export function buildAdaptiveMemoryContext(opts: {
|
||||
store: MemoryStore;
|
||||
userMessage: string;
|
||||
recentMessages: Message[];
|
||||
config: AdaptiveMemoryConfig;
|
||||
}): string {
|
||||
const sections = opts.store.getPromptSections();
|
||||
if (sections.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const maxChars = Math.max(1, opts.config.maxTokens) * 4;
|
||||
const sectionBlocks = buildSectionBlocks({
|
||||
sections,
|
||||
userMessage: opts.userMessage,
|
||||
recentMessages: opts.recentMessages,
|
||||
maxSnippetsPerSection: opts.config.maxSnippetsPerSection ?? 6,
|
||||
});
|
||||
|
||||
if (sectionBlocks.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const blocksByPriority = sectionBlocks.sort((a, b) => b.score - a.score || a.index - b.index);
|
||||
const selected: typeof blocksByPriority = [];
|
||||
let usedChars = 0;
|
||||
|
||||
for (const block of blocksByPriority) {
|
||||
const withSeparator = selected.length > 0 ? 2 : 0;
|
||||
const projectedChars = usedChars + withSeparator + block.content.length;
|
||||
if (projectedChars > maxChars) {
|
||||
continue;
|
||||
}
|
||||
selected.push(block);
|
||||
usedChars = projectedChars;
|
||||
}
|
||||
|
||||
if (selected.length === 0) {
|
||||
const best = blocksByPriority[0];
|
||||
return best.content.slice(0, maxChars);
|
||||
}
|
||||
|
||||
selected.sort((a, b) => a.index - b.index);
|
||||
return selected.map((block) => block.content).join('\n\n');
|
||||
}
|
||||
|
||||
export function buildRecentMemoryContext(store: MemoryStore, maxTokens: number): string {
|
||||
const full = store.getContextForPrompt();
|
||||
if (!full) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const maxChars = Math.max(1, maxTokens) * 4;
|
||||
if (full.length <= maxChars) {
|
||||
return full;
|
||||
}
|
||||
return full.slice(-maxChars);
|
||||
}
|
||||
|
||||
function buildSectionBlocks(opts: {
|
||||
sections: PromptMemorySection[];
|
||||
userMessage: string;
|
||||
recentMessages: Message[];
|
||||
maxSnippetsPerSection: number;
|
||||
}): Array<{ score: number; content: string; index: number }> {
|
||||
const weightedContextTokens = buildWeightedContextTokens(opts.userMessage, opts.recentMessages);
|
||||
const blocks: Array<{ score: number; content: string; index: number }> = [];
|
||||
|
||||
for (let sectionIndex = 0; sectionIndex < opts.sections.length; sectionIndex++) {
|
||||
const section = opts.sections[sectionIndex];
|
||||
const snippets = splitIntoSnippets(section.content).slice(-opts.maxSnippetsPerSection);
|
||||
if (snippets.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const scored = scoreSnippets(snippets, weightedContextTokens);
|
||||
const bestSnippets = scored
|
||||
.filter((snippet) => snippet.overlapScore > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, Math.min(3, scored.length))
|
||||
.map((snippet) => snippet.text.trim())
|
||||
.filter((snippet) => snippet.length > 0);
|
||||
|
||||
if (bestSnippets.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const block = `## ${section.title}\n\n${bestSnippets.join('\n\n')}`;
|
||||
const blockScore = scored.reduce((sum, item) => sum + item.score, 0) / scored.length;
|
||||
blocks.push({ score: blockScore, content: block, index: sectionIndex });
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function splitIntoSnippets(content: string): string[] {
|
||||
return content
|
||||
.split(/\n\s*\n/g)
|
||||
.map(part => part.trim())
|
||||
.filter(part => part.length > 0);
|
||||
}
|
||||
|
||||
function scoreSnippets(snippets: string[], weightedContextTokens: Map<string, number>): ScoredSnippet[] {
|
||||
const totalContextWeight = Math.max(
|
||||
1,
|
||||
Array.from(weightedContextTokens.values()).reduce((sum, value) => sum + value, 0),
|
||||
);
|
||||
|
||||
return snippets.map((snippet, index) => {
|
||||
const snippetTokens = new Set(tokenize(snippet));
|
||||
let overlapWeight = 0;
|
||||
|
||||
for (const token of snippetTokens) {
|
||||
overlapWeight += weightedContextTokens.get(token) ?? 0;
|
||||
}
|
||||
|
||||
const overlapScore = overlapWeight / totalContextWeight;
|
||||
const recencyScore = (index + 1) / snippets.length;
|
||||
const score = overlapScore * 0.8 + recencyScore * 0.2;
|
||||
|
||||
return { text: snippet, score, overlapScore };
|
||||
});
|
||||
}
|
||||
|
||||
function buildWeightedContextTokens(userMessage: string, recentMessages: Message[]): Map<string, number> {
|
||||
const weighted = new Map<string, number>();
|
||||
const recent = recentMessages.slice(-6);
|
||||
|
||||
addTokens(weighted, userMessage, 1.0);
|
||||
|
||||
for (let i = 0; i < recent.length; i++) {
|
||||
const msg = recent[i];
|
||||
const recencyWeight = 0.25 + ((i + 1) / recent.length) * 0.55;
|
||||
addTokens(weighted, typeof msg.content === 'string' ? msg.content : '', recencyWeight);
|
||||
}
|
||||
|
||||
return weighted;
|
||||
}
|
||||
|
||||
function addTokens(target: Map<string, number>, text: string, weight: number): void {
|
||||
for (const token of tokenize(text)) {
|
||||
target.set(token, (target.get(token) ?? 0) + weight);
|
||||
}
|
||||
}
|
||||
|
||||
function tokenize(text: string): string[] {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter(token => token.length >= 3 && !STOPWORDS.has(token));
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { MEMORY_CATEGORIES, isMemoryCategory, categoryNamespace } from './categories.js';
|
||||
|
||||
describe('memory categories', () => {
|
||||
it('exposes expected categories', () => {
|
||||
expect(MEMORY_CATEGORIES).toEqual(['facts', 'preferences', 'decisions', 'projects']);
|
||||
});
|
||||
|
||||
it('validates category names', () => {
|
||||
expect(isMemoryCategory('facts')).toBe(true);
|
||||
expect(isMemoryCategory('preferences')).toBe(true);
|
||||
expect(isMemoryCategory('decisions')).toBe(true);
|
||||
expect(isMemoryCategory('projects')).toBe(true);
|
||||
expect(isMemoryCategory('unknown')).toBe(false);
|
||||
expect(isMemoryCategory('')).toBe(false);
|
||||
});
|
||||
|
||||
it('builds category namespaces', () => {
|
||||
expect(categoryNamespace('user', 'facts')).toBe('user/facts');
|
||||
expect(categoryNamespace('global', 'decisions')).toBe('global/decisions');
|
||||
expect(categoryNamespace('sessions/abc123', 'projects')).toBe('sessions/abc123/projects');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
export const MEMORY_CATEGORIES = ['facts', 'preferences', 'decisions', 'projects'] as const;
|
||||
|
||||
export type MemoryCategory = (typeof MEMORY_CATEGORIES)[number];
|
||||
|
||||
export function isMemoryCategory(value: string): value is MemoryCategory {
|
||||
return (MEMORY_CATEGORIES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function categoryNamespace(baseNamespace: string, category: MemoryCategory): string {
|
||||
return `${baseNamespace}/${category}`;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { RoutingPolicy } from './policy.js';
|
||||
export type { RoutingPath, RoutingPolicyConfig, RoutingDecisionInput, RoutingDecision } from './policy.js';
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { RoutingPolicy } from './policy.js';
|
||||
|
||||
describe('RoutingPolicy', () => {
|
||||
it('uses default path when disabled', () => {
|
||||
const policy = new RoutingPolicy({
|
||||
enabled: false,
|
||||
fastPathThreshold: 0.8,
|
||||
llmThreshold: 0.4,
|
||||
defaultPath: 'llm',
|
||||
});
|
||||
|
||||
expect(policy.decide({ confidence: 0.99 })).toEqual({ path: 'llm', reason: 'disabled' });
|
||||
});
|
||||
|
||||
it('routes to fast at or above fast threshold', () => {
|
||||
const policy = new RoutingPolicy({
|
||||
enabled: true,
|
||||
fastPathThreshold: 0.8,
|
||||
llmThreshold: 0.4,
|
||||
defaultPath: 'llm',
|
||||
});
|
||||
|
||||
expect(policy.decide({ confidence: 0.8 })).toEqual({ path: 'fast', reason: 'high_confidence' });
|
||||
expect(policy.decide({ confidence: 0.95 })).toEqual({ path: 'fast', reason: 'high_confidence' });
|
||||
});
|
||||
|
||||
it('routes to llm at or below llm threshold', () => {
|
||||
const policy = new RoutingPolicy({
|
||||
enabled: true,
|
||||
fastPathThreshold: 0.8,
|
||||
llmThreshold: 0.4,
|
||||
defaultPath: 'fast',
|
||||
});
|
||||
|
||||
expect(policy.decide({ confidence: 0.4 })).toEqual({ path: 'llm', reason: 'low_confidence' });
|
||||
expect(policy.decide({ confidence: 0.1 })).toEqual({ path: 'llm', reason: 'low_confidence' });
|
||||
});
|
||||
|
||||
it('uses default path between thresholds', () => {
|
||||
const policy = new RoutingPolicy({
|
||||
enabled: true,
|
||||
fastPathThreshold: 0.8,
|
||||
llmThreshold: 0.4,
|
||||
defaultPath: 'llm',
|
||||
});
|
||||
|
||||
expect(policy.decide({ confidence: 0.6 })).toEqual({ path: 'llm', reason: 'mid_confidence' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
export type RoutingPath = 'fast' | 'llm';
|
||||
|
||||
export interface RoutingPolicyConfig {
|
||||
enabled: boolean;
|
||||
fastPathThreshold: number;
|
||||
llmThreshold: number;
|
||||
defaultPath: RoutingPath;
|
||||
}
|
||||
|
||||
export interface RoutingDecisionInput {
|
||||
confidence: number | null;
|
||||
}
|
||||
|
||||
export interface RoutingDecision {
|
||||
path: RoutingPath;
|
||||
reason: 'disabled' | 'no_match' | 'high_confidence' | 'low_confidence' | 'mid_confidence';
|
||||
}
|
||||
|
||||
export class RoutingPolicy {
|
||||
private readonly config: RoutingPolicyConfig;
|
||||
|
||||
constructor(config: RoutingPolicyConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.config.enabled;
|
||||
}
|
||||
|
||||
decide(input: RoutingDecisionInput): RoutingDecision {
|
||||
if (!this.config.enabled) {
|
||||
return {
|
||||
path: this.config.defaultPath,
|
||||
reason: 'disabled',
|
||||
};
|
||||
}
|
||||
|
||||
if (input.confidence === null) {
|
||||
return {
|
||||
path: this.config.defaultPath,
|
||||
reason: 'no_match',
|
||||
};
|
||||
}
|
||||
|
||||
if (input.confidence >= this.config.fastPathThreshold) {
|
||||
return {
|
||||
path: 'fast',
|
||||
reason: 'high_confidence',
|
||||
};
|
||||
}
|
||||
|
||||
if (input.confidence <= this.config.llmThreshold) {
|
||||
return {
|
||||
path: 'llm',
|
||||
reason: 'low_confidence',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
path: this.config.defaultPath,
|
||||
reason: 'mid_confidence',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { SessionIndexer, tokenize } from './indexer.js';
|
||||
|
||||
describe('session indexer', () => {
|
||||
it('tokenizes text with stopword filtering', () => {
|
||||
const tokens = tokenize('Deploy the backend service to production and verify logs');
|
||||
expect(tokens).toContain('deploy');
|
||||
expect(tokens).toContain('backend');
|
||||
expect(tokens).toContain('service');
|
||||
expect(tokens).not.toContain('the');
|
||||
});
|
||||
|
||||
it('extracts top keywords and topics', () => {
|
||||
const indexer = new SessionIndexer({ maxKeywords: 5 });
|
||||
const metadata = indexer.indexText('deploy deploy backend service backend api release');
|
||||
|
||||
expect(metadata.keywords.length).toBeLessThanOrEqual(5);
|
||||
expect(metadata.keywords).toContain('deploy');
|
||||
expect(metadata.topics.length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
export interface HistoryMetadata {
|
||||
keywords: string[];
|
||||
topics: string[];
|
||||
}
|
||||
|
||||
export interface HistoryIndexerConfig {
|
||||
maxKeywords: number;
|
||||
}
|
||||
|
||||
const STOPWORDS = new Set([
|
||||
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'has', 'he', 'in', 'is', 'it', 'its',
|
||||
'of', 'on', 'or', 'that', 'the', 'to', 'was', 'were', 'will', 'with', 'you', 'your', 'we', 'this',
|
||||
'they', 'their', 'our', 'but', 'not', 'can', 'just', 'into', 'about', 'after', 'before', 'than', 'then',
|
||||
]);
|
||||
|
||||
export function tokenize(text: string): string[] {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter(token => token.length >= 3 && !STOPWORDS.has(token));
|
||||
}
|
||||
|
||||
export class SessionIndexer {
|
||||
private readonly maxKeywords: number;
|
||||
|
||||
constructor(config: HistoryIndexerConfig) {
|
||||
this.maxKeywords = config.maxKeywords;
|
||||
}
|
||||
|
||||
indexText(text: string): HistoryMetadata {
|
||||
const tokens = tokenize(text);
|
||||
const frequencies = new Map<string, number>();
|
||||
|
||||
for (const token of tokens) {
|
||||
frequencies.set(token, (frequencies.get(token) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const sorted = Array.from(frequencies.entries())
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.slice(0, this.maxKeywords)
|
||||
.map(([token]) => token);
|
||||
|
||||
return {
|
||||
keywords: sorted,
|
||||
topics: sorted.slice(0, Math.min(3, sorted.length)),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { SessionStore } from './store.js';
|
||||
import { SessionIndexer } from './indexer.js';
|
||||
import { SessionSearch } from './search.js';
|
||||
import { unlinkSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
describe('SessionSearch', () => {
|
||||
const dbPath = join(tmpdir(), 'flynn-test-search.db');
|
||||
let store: SessionStore;
|
||||
let search: SessionSearch;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new SessionStore(dbPath);
|
||||
const indexer = new SessionIndexer({ maxKeywords: 8 });
|
||||
store.addMessage('session:a', { role: 'user', content: 'deploy backend service' }, indexer.indexText('deploy backend service'));
|
||||
store.addMessage('session:a', { role: 'assistant', content: 'backend deployment completed' }, indexer.indexText('backend deployment completed'));
|
||||
store.addMessage('session:b', { role: 'user', content: 'buy groceries tonight' }, indexer.indexText('buy groceries tonight'));
|
||||
|
||||
search = new SessionSearch(store, {
|
||||
limit: 10,
|
||||
minScore: 0.1,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
if (existsSync(dbPath)) {
|
||||
unlinkSync(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns ranked results for overlapping keywords', () => {
|
||||
const results = search.search('deploy backend');
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].sessionId).toBe('session:a');
|
||||
expect(results[0].score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('supports session-specific filtering', () => {
|
||||
const results = search.search('deploy', { sessionId: 'session:b' });
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { SessionStore } from './store.js';
|
||||
import { tokenize } from './indexer.js';
|
||||
|
||||
export interface HistorySearchResult {
|
||||
sessionId: string;
|
||||
messageId: number;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
score: number;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface HistorySearchConfig {
|
||||
limit: number;
|
||||
minScore: number;
|
||||
}
|
||||
|
||||
export class SessionSearch {
|
||||
private readonly store: SessionStore;
|
||||
private readonly config: HistorySearchConfig;
|
||||
|
||||
constructor(store: SessionStore, config: HistorySearchConfig) {
|
||||
this.store = store;
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
search(query: string, opts?: { limit?: number; sessionId?: string }): HistorySearchResult[] {
|
||||
const queryTokens = new Set(tokenize(query));
|
||||
if (queryTokens.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = opts?.sessionId
|
||||
? this.store.getMessagesWithMetadata(opts.sessionId)
|
||||
: this.store.getAllMessagesWithMetadata();
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const results: HistorySearchResult[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const keywords = row.metadata?.keywords ?? [];
|
||||
if (keywords.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const overlapCount = keywords.filter(keyword => queryTokens.has(keyword)).length;
|
||||
if (overlapCount === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const overlapScore = overlapCount / queryTokens.size;
|
||||
const ageSeconds = Math.max(0, now - row.createdAt);
|
||||
const recencyScore = Math.max(0, 1 - ageSeconds / (30 * 24 * 3600)) * 0.2;
|
||||
const totalScore = overlapScore + recencyScore;
|
||||
|
||||
if (totalScore < this.config.minScore) {
|
||||
continue;
|
||||
}
|
||||
|
||||
results.push({
|
||||
sessionId: row.sessionId,
|
||||
messageId: row.id,
|
||||
role: row.role,
|
||||
content: row.content,
|
||||
score: totalScore,
|
||||
createdAt: row.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
results.sort((a, b) => b.score - a.score || b.createdAt - a.createdAt);
|
||||
return results.slice(0, opts?.limit ?? this.config.limit);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user