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

This commit is contained in:
William Valentin
2026-02-12 22:47:22 -08:00
parent 7ae0fb51c2
commit 6e8984f788
25 changed files with 1469 additions and 0 deletions
+136
View File
@@ -0,0 +1,136 @@
import type { CommandDefinition, CommandResult } from '../types.js';
import type { CommandRegistry } from '../registry.js';
function notAvailable(label: string): CommandResult {
return {
handled: true,
text: `${label} is not available in this session.`,
};
}
export function createHelpCommand(registry: CommandRegistry): CommandDefinition {
return {
name: 'help',
description: 'Show available commands',
execute: async () => {
const lines = ['Available commands:'];
for (const command of registry.list()) {
const aliases = command.aliases && command.aliases.length > 0
? ` (aliases: ${command.aliases.map(alias => `/${alias}`).join(', ')})`
: '';
lines.push(`- /${command.name}: ${command.description}${aliases}`);
}
return {
handled: true,
text: lines.join('\n'),
};
},
};
}
export function createStatusCommand(): CommandDefinition {
return {
name: 'status',
description: 'Show current status',
execute: async (_args, ctx) => {
if (!ctx.services?.getStatus) {
return {
handled: true,
text: 'Flynn is running.',
};
}
return {
handled: true,
text: await ctx.services.getStatus(),
};
},
};
}
export function createUsageCommand(): CommandDefinition {
return {
name: 'usage',
description: 'Show token usage',
execute: async (_args, ctx) => {
if (!ctx.services?.getUsage) {
return notAvailable('Usage command');
}
return {
handled: true,
text: await ctx.services.getUsage(),
};
},
};
}
export function createModelCommand(): CommandDefinition {
return {
name: 'model',
description: 'Show or change model tier',
execute: async (args, ctx) => {
if (args.length === 0) {
if (!ctx.services?.getModel) {
return notAvailable('Model command');
}
return {
handled: true,
text: await ctx.services.getModel(),
};
}
if (!ctx.services?.setModel) {
return notAvailable('Model command');
}
return {
handled: true,
text: await ctx.services.setModel(args[0]),
};
},
};
}
export function createCompactCommand(): CommandDefinition {
return {
name: 'compact',
description: 'Compact conversation context',
execute: async (_args, ctx) => {
if (!ctx.services?.compact) {
return notAvailable('Compact command');
}
return {
handled: true,
text: await ctx.services.compact(),
};
},
};
}
export function createResetCommand(): CommandDefinition {
return {
name: 'reset',
description: 'Reset current session',
execute: async (_args, ctx) => {
if (!ctx.services?.reset) {
return notAvailable('Reset command');
}
return {
handled: true,
text: await ctx.services.reset(),
};
},
};
}
export function registerBuiltinCommands(registry: CommandRegistry): void {
registry.register(createHelpCommand(registry));
registry.register(createStatusCommand());
registry.register(createUsageCommand());
registry.register(createModelCommand());
registry.register(createCompactCommand());
registry.register(createResetCommand());
}
+11
View File
@@ -0,0 +1,11 @@
export { CommandRegistry } from './registry.js';
export type { CommandContext, CommandDefinition, CommandResult, CommandServices } from './types.js';
export {
createHelpCommand,
createStatusCommand,
createUsageCommand,
createModelCommand,
createCompactCommand,
createResetCommand,
registerBuiltinCommands,
} from './builtin/index.js';
+84
View File
@@ -0,0 +1,84 @@
import { describe, it, expect } from 'vitest';
import { CommandRegistry } from './registry.js';
describe('CommandRegistry', () => {
it('registers commands and retrieves by name/alias', () => {
const registry = new CommandRegistry();
registry.register({
name: 'help',
aliases: ['h'],
description: 'show help',
execute: async () => ({ handled: true, text: 'ok' }),
});
expect(registry.get('help')?.name).toBe('help');
expect(registry.get('/help')?.name).toBe('help');
expect(registry.get('h')?.name).toBe('help');
expect(registry.get('/h')?.name).toBe('help');
expect(registry.list()).toHaveLength(1);
});
it('parses slash command input', () => {
const registry = new CommandRegistry();
expect(registry.isCommand('/help')).toBe(true);
expect(registry.parse('/model fast')).toEqual({
name: 'model',
args: ['fast'],
});
expect(registry.parse('hello')).toBeNull();
expect(registry.parse('/')).toBeNull();
});
it('executes known command and returns handled result', async () => {
const registry = new CommandRegistry();
registry.register({
name: 'status',
description: 'show status',
execute: async (_args, ctx) => ({ handled: true, text: `ok:${ctx.channel}` }),
});
const result = await registry.execute('/status', {
channel: 'telegram',
senderId: 'u1',
sessionId: 'telegram:u1',
rawInput: '/status',
});
expect(result).toEqual({ handled: true, text: 'ok:telegram' });
});
it('returns handled=false for unknown commands', async () => {
const registry = new CommandRegistry();
const result = await registry.execute('/unknown', {
channel: 'telegram',
senderId: 'u1',
sessionId: 'telegram:u1',
rawInput: '/unknown',
});
expect(result).toEqual({ handled: false, text: '' });
});
it('catches handler errors and returns safe message', async () => {
const registry = new CommandRegistry();
registry.register({
name: 'boom',
description: 'throws',
execute: async () => {
throw new Error('bad things');
},
});
const result = await registry.execute('/boom', {
channel: 'telegram',
senderId: 'u1',
sessionId: 'telegram:u1',
rawInput: '/boom',
});
expect(result.handled).toBe(true);
expect(result.text).toContain('Command failed: bad things');
});
});
+97
View File
@@ -0,0 +1,97 @@
import type { CommandContext, CommandDefinition, CommandResult } from './types.js';
const MAX_INPUT_LENGTH = 2000;
export class CommandRegistry {
private commands = new Map<string, CommandDefinition>();
private aliasToCommand = new Map<string, string>();
register(def: CommandDefinition): void {
const canonicalName = this.normalizeName(def.name);
if (!canonicalName) {
throw new Error('Command name is required');
}
if (this.commands.has(canonicalName)) {
throw new Error(`Command already registered: ${canonicalName}`);
}
this.commands.set(canonicalName, { ...def, name: canonicalName });
for (const alias of def.aliases ?? []) {
const canonicalAlias = this.normalizeName(alias);
if (!canonicalAlias) {
continue;
}
if (canonicalAlias === canonicalName || this.aliasToCommand.has(canonicalAlias) || this.commands.has(canonicalAlias)) {
throw new Error(`Command alias already registered: ${canonicalAlias}`);
}
this.aliasToCommand.set(canonicalAlias, canonicalName);
}
}
get(nameOrAlias: string): CommandDefinition | undefined {
const normalized = this.normalizeName(nameOrAlias);
if (!normalized) {
return undefined;
}
const canonicalName = this.aliasToCommand.get(normalized) ?? normalized;
return this.commands.get(canonicalName);
}
list(): CommandDefinition[] {
return Array.from(this.commands.values());
}
isCommand(input: string): boolean {
return this.parse(input) !== null;
}
parse(input: string): { name: string; args: string[] } | null {
const trimmed = input.trim();
if (!trimmed.startsWith('/') || trimmed.length > MAX_INPUT_LENGTH) {
return null;
}
const withoutSlash = trimmed.slice(1).trim();
if (!withoutSlash) {
return null;
}
const [rawName, ...rest] = withoutSlash.split(/\s+/);
const name = this.normalizeName(rawName);
if (!name) {
return null;
}
return {
name,
args: rest,
};
}
async execute(input: string, ctx: CommandContext): Promise<CommandResult> {
const parsed = this.parse(input);
if (!parsed) {
return { handled: false, text: '' };
}
const command = this.get(parsed.name);
if (!command) {
return { handled: false, text: '' };
}
try {
return await command.execute(parsed.args, ctx);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown command error';
return {
handled: true,
text: `Command failed: ${message}`,
};
}
}
private normalizeName(value: string): string {
return value.trim().replace(/^\//, '').toLowerCase();
}
}
+28
View File
@@ -0,0 +1,28 @@
export interface CommandContext {
channel: string;
senderId: string;
sessionId: string;
rawInput: string;
services?: CommandServices;
}
export interface CommandResult {
handled: boolean;
text: string;
}
export interface CommandDefinition {
name: string;
aliases?: string[];
description: string;
execute: (args: string[], ctx: CommandContext) => Promise<CommandResult>;
}
export interface CommandServices {
getStatus?: () => Promise<string> | string;
getUsage?: () => Promise<string> | string;
getModel?: () => Promise<string> | string;
setModel?: (tier: string) => Promise<string> | string;
compact?: () => Promise<string> | string;
reset?: () => Promise<string> | string;
}
+46
View File
@@ -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);
});
});
+73
View File
@@ -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;
}
+104
View File
@@ -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');
});
});
+30
View File
@@ -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 });
},
};
}
+33
View File
@@ -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,
});
},
};
}
+31
View File
@@ -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,
});
},
};
}
+2
View File
@@ -0,0 +1,2 @@
export { ComponentRegistry } from './registry.js';
export type { IntentRule, IntentTarget, IntentTargetType, IntentMatch, ComponentRegistryConfig } from './registry.js';
+74
View File
@@ -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();
});
});
+142
View File
@@ -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 };
}
}
+70
View File
@@ -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);
});
});
+171
View File
@@ -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));
}
+23
View File
@@ -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');
});
});
+11
View File
@@ -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}`;
}
+2
View File
@@ -0,0 +1,2 @@
export { RoutingPolicy } from './policy.js';
export type { RoutingPath, RoutingPolicyConfig, RoutingDecisionInput, RoutingDecision } from './policy.js';
+50
View File
@@ -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' });
});
});
+64
View File
@@ -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',
};
}
}
+21
View File
@@ -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);
});
});
+48
View File
@@ -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)),
};
}
}
+45
View File
@@ -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([]);
});
});
+73
View File
@@ -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);
}
}