feat(core): add command, intent, and routing primitives
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { tmpdir } from 'os';
|
||||
import { MemoryStore } from './store.js';
|
||||
import { buildAdaptiveMemoryContext, buildRecentMemoryContext } from './adaptive.js';
|
||||
import type { Message } from '../models/types.js';
|
||||
|
||||
describe('adaptive memory context', () => {
|
||||
let dir: string;
|
||||
let store: MemoryStore;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), 'flynn-adaptive-memory-test-'));
|
||||
store = new MemoryStore({ dir, maxContextTokens: 4000 });
|
||||
|
||||
store.write('user', 'Preferred language: TypeScript', 'replace');
|
||||
store.writeCategory('user', 'preferences', [
|
||||
'Prefers concise responses and direct code snippets.',
|
||||
'Use markdown tables for summaries.',
|
||||
'Avoid unnecessary setup details.',
|
||||
].join('\n\n'), 'replace');
|
||||
store.writeCategory('global', 'facts', [
|
||||
'Project uses pnpm and Vitest.',
|
||||
'Release workflow runs lint, test, and build.',
|
||||
'Kubernetes cluster is k0s on arm64.',
|
||||
].join('\n\n'), 'replace');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('selects memory snippets relevant to the current message', () => {
|
||||
const recentMessages: Message[] = [
|
||||
{ role: 'user', content: 'Please keep answers concise' },
|
||||
{ role: 'assistant', content: 'Understood' },
|
||||
];
|
||||
|
||||
const context = buildAdaptiveMemoryContext({
|
||||
store,
|
||||
userMessage: 'Can you provide a concise summary in markdown table format?',
|
||||
recentMessages,
|
||||
config: { maxTokens: 200 },
|
||||
});
|
||||
|
||||
expect(context).toContain('User Preferences');
|
||||
expect(context).toContain('markdown tables');
|
||||
expect(context).not.toContain('Kubernetes cluster is k0s on arm64');
|
||||
});
|
||||
|
||||
it('respects token budget clipping', () => {
|
||||
const context = buildAdaptiveMemoryContext({
|
||||
store,
|
||||
userMessage: 'Tell me something about this project',
|
||||
recentMessages: [],
|
||||
config: { maxTokens: 20 },
|
||||
});
|
||||
|
||||
expect(context.length).toBeLessThanOrEqual(80);
|
||||
});
|
||||
|
||||
it('recent strategy keeps the tail of memory context', () => {
|
||||
const recentContext = buildRecentMemoryContext(store, 25);
|
||||
const fullContext = store.getContextForPrompt();
|
||||
|
||||
expect(recentContext.length).toBeLessThanOrEqual(100);
|
||||
expect(fullContext.endsWith(recentContext)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { Message } from '../models/types.js';
|
||||
import type { MemoryStore, PromptMemorySection } from './store.js';
|
||||
|
||||
export interface AdaptiveMemoryConfig {
|
||||
maxTokens: number;
|
||||
maxSnippetsPerSection?: number;
|
||||
}
|
||||
|
||||
interface ScoredSnippet {
|
||||
text: string;
|
||||
score: number;
|
||||
overlapScore: number;
|
||||
}
|
||||
|
||||
const STOPWORDS = new Set([
|
||||
'a', 'an', 'and', 'are', 'as', 'at', 'be', 'by', 'for', 'from', 'has', 'he', 'in', 'is', 'it', 'its',
|
||||
'of', 'on', 'or', 'that', 'the', 'to', 'was', 'were', 'will', 'with', 'you', 'your', 'we', 'this',
|
||||
'they', 'their', 'our', 'but', 'not', 'can', 'just', 'into', 'about', 'after', 'before', 'than', 'then',
|
||||
]);
|
||||
|
||||
export function buildAdaptiveMemoryContext(opts: {
|
||||
store: MemoryStore;
|
||||
userMessage: string;
|
||||
recentMessages: Message[];
|
||||
config: AdaptiveMemoryConfig;
|
||||
}): string {
|
||||
const sections = opts.store.getPromptSections();
|
||||
if (sections.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const maxChars = Math.max(1, opts.config.maxTokens) * 4;
|
||||
const sectionBlocks = buildSectionBlocks({
|
||||
sections,
|
||||
userMessage: opts.userMessage,
|
||||
recentMessages: opts.recentMessages,
|
||||
maxSnippetsPerSection: opts.config.maxSnippetsPerSection ?? 6,
|
||||
});
|
||||
|
||||
if (sectionBlocks.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const blocksByPriority = sectionBlocks.sort((a, b) => b.score - a.score || a.index - b.index);
|
||||
const selected: typeof blocksByPriority = [];
|
||||
let usedChars = 0;
|
||||
|
||||
for (const block of blocksByPriority) {
|
||||
const withSeparator = selected.length > 0 ? 2 : 0;
|
||||
const projectedChars = usedChars + withSeparator + block.content.length;
|
||||
if (projectedChars > maxChars) {
|
||||
continue;
|
||||
}
|
||||
selected.push(block);
|
||||
usedChars = projectedChars;
|
||||
}
|
||||
|
||||
if (selected.length === 0) {
|
||||
const best = blocksByPriority[0];
|
||||
return best.content.slice(0, maxChars);
|
||||
}
|
||||
|
||||
selected.sort((a, b) => a.index - b.index);
|
||||
return selected.map((block) => block.content).join('\n\n');
|
||||
}
|
||||
|
||||
export function buildRecentMemoryContext(store: MemoryStore, maxTokens: number): string {
|
||||
const full = store.getContextForPrompt();
|
||||
if (!full) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const maxChars = Math.max(1, maxTokens) * 4;
|
||||
if (full.length <= maxChars) {
|
||||
return full;
|
||||
}
|
||||
return full.slice(-maxChars);
|
||||
}
|
||||
|
||||
function buildSectionBlocks(opts: {
|
||||
sections: PromptMemorySection[];
|
||||
userMessage: string;
|
||||
recentMessages: Message[];
|
||||
maxSnippetsPerSection: number;
|
||||
}): Array<{ score: number; content: string; index: number }> {
|
||||
const weightedContextTokens = buildWeightedContextTokens(opts.userMessage, opts.recentMessages);
|
||||
const blocks: Array<{ score: number; content: string; index: number }> = [];
|
||||
|
||||
for (let sectionIndex = 0; sectionIndex < opts.sections.length; sectionIndex++) {
|
||||
const section = opts.sections[sectionIndex];
|
||||
const snippets = splitIntoSnippets(section.content).slice(-opts.maxSnippetsPerSection);
|
||||
if (snippets.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const scored = scoreSnippets(snippets, weightedContextTokens);
|
||||
const bestSnippets = scored
|
||||
.filter((snippet) => snippet.overlapScore > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, Math.min(3, scored.length))
|
||||
.map((snippet) => snippet.text.trim())
|
||||
.filter((snippet) => snippet.length > 0);
|
||||
|
||||
if (bestSnippets.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const block = `## ${section.title}\n\n${bestSnippets.join('\n\n')}`;
|
||||
const blockScore = scored.reduce((sum, item) => sum + item.score, 0) / scored.length;
|
||||
blocks.push({ score: blockScore, content: block, index: sectionIndex });
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function splitIntoSnippets(content: string): string[] {
|
||||
return content
|
||||
.split(/\n\s*\n/g)
|
||||
.map(part => part.trim())
|
||||
.filter(part => part.length > 0);
|
||||
}
|
||||
|
||||
function scoreSnippets(snippets: string[], weightedContextTokens: Map<string, number>): ScoredSnippet[] {
|
||||
const totalContextWeight = Math.max(
|
||||
1,
|
||||
Array.from(weightedContextTokens.values()).reduce((sum, value) => sum + value, 0),
|
||||
);
|
||||
|
||||
return snippets.map((snippet, index) => {
|
||||
const snippetTokens = new Set(tokenize(snippet));
|
||||
let overlapWeight = 0;
|
||||
|
||||
for (const token of snippetTokens) {
|
||||
overlapWeight += weightedContextTokens.get(token) ?? 0;
|
||||
}
|
||||
|
||||
const overlapScore = overlapWeight / totalContextWeight;
|
||||
const recencyScore = (index + 1) / snippets.length;
|
||||
const score = overlapScore * 0.8 + recencyScore * 0.2;
|
||||
|
||||
return { text: snippet, score, overlapScore };
|
||||
});
|
||||
}
|
||||
|
||||
function buildWeightedContextTokens(userMessage: string, recentMessages: Message[]): Map<string, number> {
|
||||
const weighted = new Map<string, number>();
|
||||
const recent = recentMessages.slice(-6);
|
||||
|
||||
addTokens(weighted, userMessage, 1.0);
|
||||
|
||||
for (let i = 0; i < recent.length; i++) {
|
||||
const msg = recent[i];
|
||||
const recencyWeight = 0.25 + ((i + 1) / recent.length) * 0.55;
|
||||
addTokens(weighted, typeof msg.content === 'string' ? msg.content : '', recencyWeight);
|
||||
}
|
||||
|
||||
return weighted;
|
||||
}
|
||||
|
||||
function addTokens(target: Map<string, number>, text: string, weight: number): void {
|
||||
for (const token of tokenize(text)) {
|
||||
target.set(token, (target.get(token) ?? 0) + weight);
|
||||
}
|
||||
}
|
||||
|
||||
function tokenize(text: string): string[] {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter(token => token.length >= 3 && !STOPWORDS.has(token));
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { MEMORY_CATEGORIES, isMemoryCategory, categoryNamespace } from './categories.js';
|
||||
|
||||
describe('memory categories', () => {
|
||||
it('exposes expected categories', () => {
|
||||
expect(MEMORY_CATEGORIES).toEqual(['facts', 'preferences', 'decisions', 'projects']);
|
||||
});
|
||||
|
||||
it('validates category names', () => {
|
||||
expect(isMemoryCategory('facts')).toBe(true);
|
||||
expect(isMemoryCategory('preferences')).toBe(true);
|
||||
expect(isMemoryCategory('decisions')).toBe(true);
|
||||
expect(isMemoryCategory('projects')).toBe(true);
|
||||
expect(isMemoryCategory('unknown')).toBe(false);
|
||||
expect(isMemoryCategory('')).toBe(false);
|
||||
});
|
||||
|
||||
it('builds category namespaces', () => {
|
||||
expect(categoryNamespace('user', 'facts')).toBe('user/facts');
|
||||
expect(categoryNamespace('global', 'decisions')).toBe('global/decisions');
|
||||
expect(categoryNamespace('sessions/abc123', 'projects')).toBe('sessions/abc123/projects');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
export const MEMORY_CATEGORIES = ['facts', 'preferences', 'decisions', 'projects'] as const;
|
||||
|
||||
export type MemoryCategory = (typeof MEMORY_CATEGORIES)[number];
|
||||
|
||||
export function isMemoryCategory(value: string): value is MemoryCategory {
|
||||
return (MEMORY_CATEGORIES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function categoryNamespace(baseNamespace: string, category: MemoryCategory): string {
|
||||
return `${baseNamespace}/${category}`;
|
||||
}
|
||||
Reference in New Issue
Block a user