feat(core): add command, intent, and routing primitives
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user