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
+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);
}
}