diff --git a/src/commands/builtin/index.ts b/src/commands/builtin/index.ts new file mode 100644 index 0000000..d717234 --- /dev/null +++ b/src/commands/builtin/index.ts @@ -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()); +} diff --git a/src/commands/index.ts b/src/commands/index.ts new file mode 100644 index 0000000..ee13182 --- /dev/null +++ b/src/commands/index.ts @@ -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'; diff --git a/src/commands/registry.test.ts b/src/commands/registry.test.ts new file mode 100644 index 0000000..8452567 --- /dev/null +++ b/src/commands/registry.test.ts @@ -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'); + }); +}); diff --git a/src/commands/registry.ts b/src/commands/registry.ts new file mode 100644 index 0000000..5734855 --- /dev/null +++ b/src/commands/registry.ts @@ -0,0 +1,97 @@ +import type { CommandContext, CommandDefinition, CommandResult } from './types.js'; + +const MAX_INPUT_LENGTH = 2000; + +export class CommandRegistry { + private commands = new Map(); + private aliasToCommand = new Map(); + + 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 { + 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(); + } +} diff --git a/src/commands/types.ts b/src/commands/types.ts new file mode 100644 index 0000000..b83ad23 --- /dev/null +++ b/src/commands/types.ts @@ -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; +} + +export interface CommandServices { + getStatus?: () => Promise | string; + getUsage?: () => Promise | string; + getModel?: () => Promise | string; + setModel?: (tier: string) => Promise | string; + compact?: () => Promise | string; + reset?: () => Promise | string; +} diff --git a/src/context/weighting.test.ts b/src/context/weighting.test.ts new file mode 100644 index 0000000..05ba850 --- /dev/null +++ b/src/context/weighting.test.ts @@ -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); + }); +}); diff --git a/src/context/weighting.ts b/src/context/weighting.ts new file mode 100644 index 0000000..75a2097 --- /dev/null +++ b/src/context/weighting.ts @@ -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; +} diff --git a/src/gateway/handlers/agent.test.ts b/src/gateway/handlers/agent.test.ts new file mode 100644 index 0000000..f22d991 --- /dev/null +++ b/src/gateway/handlers/agent.test.ts @@ -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'); + }); +}); diff --git a/src/gateway/handlers/history.ts b/src/gateway/handlers/history.ts new file mode 100644 index 0000000..16ecede --- /dev/null +++ b/src/gateway/handlers/history.ts @@ -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 => { + 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 => { + const reindexed = deps.sessionManager.reindexHistory(); + return makeResponse(request.id, { reindexed }); + }, + }; +} diff --git a/src/gateway/handlers/intents.ts b/src/gateway/handlers/intents.ts new file mode 100644 index 0000000..af01d34 --- /dev/null +++ b/src/gateway/handlers/intents.ts @@ -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 => { + const rules = deps.intentRegistry?.list() ?? []; + return makeResponse(request.id, { + enabled: deps.enabled, + rules, + }); + }, + + 'intents.match': async (request: GatewayRequest): Promise => { + 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, + }); + }, + }; +} diff --git a/src/gateway/handlers/routing.ts b/src/gateway/handlers/routing.ts new file mode 100644 index 0000000..618a150 --- /dev/null +++ b/src/gateway/handlers/routing.ts @@ -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 => { + 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, + }); + }, + }; +} diff --git a/src/intents/index.ts b/src/intents/index.ts new file mode 100644 index 0000000..e29bfb7 --- /dev/null +++ b/src/intents/index.ts @@ -0,0 +1,2 @@ +export { ComponentRegistry } from './registry.js'; +export type { IntentRule, IntentTarget, IntentTargetType, IntentMatch, ComponentRegistryConfig } from './registry.js'; diff --git a/src/intents/registry.test.ts b/src/intents/registry.test.ts new file mode 100644 index 0000000..de13d23 --- /dev/null +++ b/src/intents/registry.test.ts @@ -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(); + }); +}); diff --git a/src/intents/registry.ts b/src/intents/registry.ts new file mode 100644 index 0000000..48f595f --- /dev/null +++ b/src/intents/registry.ts @@ -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 }; + } +} diff --git a/src/memory/adaptive.test.ts b/src/memory/adaptive.test.ts new file mode 100644 index 0000000..63670fd --- /dev/null +++ b/src/memory/adaptive.test.ts @@ -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); + }); +}); diff --git a/src/memory/adaptive.ts b/src/memory/adaptive.ts new file mode 100644 index 0000000..10d8c0d --- /dev/null +++ b/src/memory/adaptive.ts @@ -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): 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 { + const weighted = new Map(); + 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, 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)); +} diff --git a/src/memory/categories.test.ts b/src/memory/categories.test.ts new file mode 100644 index 0000000..0605cec --- /dev/null +++ b/src/memory/categories.test.ts @@ -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'); + }); +}); diff --git a/src/memory/categories.ts b/src/memory/categories.ts new file mode 100644 index 0000000..9fb4a46 --- /dev/null +++ b/src/memory/categories.ts @@ -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}`; +} diff --git a/src/routing/index.ts b/src/routing/index.ts new file mode 100644 index 0000000..8fcffa6 --- /dev/null +++ b/src/routing/index.ts @@ -0,0 +1,2 @@ +export { RoutingPolicy } from './policy.js'; +export type { RoutingPath, RoutingPolicyConfig, RoutingDecisionInput, RoutingDecision } from './policy.js'; diff --git a/src/routing/policy.test.ts b/src/routing/policy.test.ts new file mode 100644 index 0000000..fe0b142 --- /dev/null +++ b/src/routing/policy.test.ts @@ -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' }); + }); +}); diff --git a/src/routing/policy.ts b/src/routing/policy.ts new file mode 100644 index 0000000..9c36fdf --- /dev/null +++ b/src/routing/policy.ts @@ -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', + }; + } +} diff --git a/src/session/indexer.test.ts b/src/session/indexer.test.ts new file mode 100644 index 0000000..c053711 --- /dev/null +++ b/src/session/indexer.test.ts @@ -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); + }); +}); diff --git a/src/session/indexer.ts b/src/session/indexer.ts new file mode 100644 index 0000000..d2ce270 --- /dev/null +++ b/src/session/indexer.ts @@ -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(); + + 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)), + }; + } +} diff --git a/src/session/search.test.ts b/src/session/search.test.ts new file mode 100644 index 0000000..6a8ce70 --- /dev/null +++ b/src/session/search.test.ts @@ -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([]); + }); +}); diff --git a/src/session/search.ts b/src/session/search.ts new file mode 100644 index 0000000..47f6d6e --- /dev/null +++ b/src/session/search.ts @@ -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); + } +}