feat(session): persist model tier overrides per session

Store per-session config in SQLite and route /model and /reset through command fast-paths so channel sessions keep independent model selection across reconnects and restarts.
This commit is contained in:
William Valentin
2026-02-13 01:04:26 -08:00
parent 3472a0b926
commit 9f81c01603
35 changed files with 1438 additions and 144 deletions
+3
View File
@@ -8,3 +8,6 @@ export { VectorStore, cosineSimilarity, contentHash } from './vector-store.js';
export type { VectorSearchResult, EmbeddingRow } from './vector-store.js';
export { HybridSearch } from './hybrid-search.js';
export type { HybridSearchResult } from './hybrid-search.js';
export * from './categories.js';
export { buildAdaptiveMemoryContext, buildRecentMemoryContext } from './adaptive.js';
export type { AdaptiveMemoryConfig } from './adaptive.js';
+63
View File
@@ -82,6 +82,45 @@ describe('MemoryStore', () => {
});
});
describe('category APIs', () => {
it('reads and writes category namespaces', () => {
store.writeCategory('user', 'facts', 'User lives in Berlin', 'replace');
expect(store.readCategory('user', 'facts')).toBe('User lives in Berlin');
});
it('supports append and replace modes in category writes', () => {
store.writeCategory('user', 'preferences', 'Prefers short answers', 'replace');
store.writeCategory('user', 'preferences', 'Likes numbered lists', 'append');
expect(store.readCategory('user', 'preferences')).toContain('Prefers short answers');
expect(store.readCategory('user', 'preferences')).toContain('Likes numbered lists');
store.writeCategory('user', 'preferences', 'Only this remains', 'replace');
const content = store.readCategory('user', 'preferences');
expect(content).toContain('Only this remains');
expect(content).not.toContain('Prefers short answers');
});
it('lists only categories that exist under a base namespace', () => {
store.writeCategory('user', 'facts', 'Fact', 'replace');
store.writeCategory('user', 'projects', 'Project', 'replace');
store.writeCategory('global', 'decisions', 'Decision', 'replace');
expect(store.listCategories('user')).toEqual(['facts', 'projects']);
expect(store.listCategories('global')).toEqual(['decisions']);
expect(store.listCategories('sessions/abc')).toEqual([]);
});
it('reads all existing categories under a base namespace', () => {
store.writeCategory('user', 'facts', 'Fact content', 'replace');
store.writeCategory('user', 'decisions', 'Decision content', 'replace');
expect(store.readAllCategories('user')).toEqual({
facts: 'Fact content',
decisions: 'Decision content',
});
});
});
describe('search', () => {
beforeEach(() => {
store.write('notes', 'The quick brown fox jumps over the lazy dog\nAnother line of text\nFox sightings are common here', 'replace');
@@ -123,6 +162,24 @@ describe('MemoryStore', () => {
const results = store.search('xyznonexistent');
expect(results).toEqual([]);
});
it('supports filtering by category', () => {
store.writeCategory('user', 'facts', 'fox factual statement', 'replace');
store.writeCategory('user', 'preferences', 'prefers fox metaphors', 'replace');
const factOnly = store.search('fox', { categories: ['facts'] });
expect(factOnly.length).toBeGreaterThan(0);
expect(factOnly.every(result => result.namespace.endsWith('/facts'))).toBe(true);
});
it('supports filtering by base namespace prefix', () => {
store.writeCategory('user', 'facts', 'fox in user facts', 'replace');
store.writeCategory('global', 'facts', 'fox in global facts', 'replace');
const userOnly = store.search('fox', { baseNamespacePrefix: 'user/' });
expect(userOnly.length).toBeGreaterThan(0);
expect(userOnly.every(result => result.namespace.startsWith('user/'))).toBe(true);
});
});
describe('listNamespaces', () => {
@@ -166,6 +223,8 @@ describe('MemoryStore', () => {
it('includes user and global memory under headings', () => {
store.write('user', 'User prefers concise answers', 'replace');
store.write('global', 'System-wide knowledge base', 'replace');
store.writeCategory('user', 'facts', 'User timezone is UTC', 'replace');
store.writeCategory('global', 'decisions', 'Adopt pnpm workspace', 'replace');
const context = store.getContextForPrompt();
@@ -174,6 +233,10 @@ describe('MemoryStore', () => {
expect(context).toContain('User prefers concise answers');
expect(context).toContain('Global Memory');
expect(context).toContain('System-wide knowledge base');
expect(context).toContain('User Facts');
expect(context).toContain('User timezone is UTC');
expect(context).toContain('Global Decisions');
expect(context).toContain('Adopt pnpm workspace');
});
it('truncates content to stay within maxContextTokens', () => {
+116 -14
View File
@@ -1,5 +1,6 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs';
import { join, relative, dirname } from 'path';
import { MEMORY_CATEGORIES, categoryNamespace, isMemoryCategory, type MemoryCategory } from './categories.js';
/**
* Configuration for the MemoryStore.
@@ -11,6 +12,11 @@ export interface MemoryStoreConfig {
maxContextTokens: number;
}
export interface PromptMemorySection {
title: string;
content: string;
}
/**
* A single search result from scanning memory files.
*/
@@ -25,6 +31,11 @@ export interface SearchResult {
context: string;
}
export interface SearchOptions {
categories?: MemoryCategory[];
baseNamespacePrefix?: string;
}
/**
* Manages persistent markdown memory files on disk.
*
@@ -94,14 +105,72 @@ export class MemoryStore {
this._dirtyNamespaces.add(namespace);
}
/** Read content for a category under a base namespace. */
readCategory(baseNamespace: string, category: MemoryCategory): string {
return this.read(categoryNamespace(baseNamespace, category));
}
/** Write content for a category under a base namespace. */
writeCategory(baseNamespace: string, category: MemoryCategory, content: string, mode: 'append' | 'replace'): void {
this.write(categoryNamespace(baseNamespace, category), content, mode);
}
/** List categories that currently exist under a base namespace. */
listCategories(baseNamespace: string): MemoryCategory[] {
const categorySet = new Set<MemoryCategory>();
const prefix = `${baseNamespace}/`;
for (const namespace of this.listNamespaces()) {
if (!namespace.startsWith(prefix)) {
continue;
}
const suffix = namespace.slice(prefix.length);
if (suffix.includes('/')) {
continue;
}
if (isMemoryCategory(suffix)) {
categorySet.add(suffix);
}
}
return MEMORY_CATEGORIES.filter(category => categorySet.has(category));
}
/** Read all category files under a base namespace. */
readAllCategories(baseNamespace: string): Partial<Record<MemoryCategory, string>> {
const result: Partial<Record<MemoryCategory, string>> = {};
for (const category of this.listCategories(baseNamespace)) {
const content = this.readCategory(baseNamespace, category);
if (content.length > 0) {
result[category] = content;
}
}
return result;
}
/**
* Search across all memory files for a keyword or phrase.
* Performs case-insensitive line-by-line matching.
* Returns matching lines with 1 line of context above and below.
*/
search(query: string): SearchResult[] {
search(query: string, opts?: SearchOptions): SearchResult[] {
const results: SearchResult[] = [];
const namespaces = this.listNamespaces();
const namespaces = this.listNamespaces().filter((namespace) => {
if (opts?.baseNamespacePrefix && !namespace.startsWith(opts.baseNamespacePrefix)) {
return false;
}
if (opts?.categories && opts.categories.length > 0) {
const suffix = namespace.split('/').pop() ?? '';
return isMemoryCategory(suffix) && opts.categories.includes(suffix);
}
return true;
});
const lowerQuery = query.toLowerCase();
for (const namespace of namespaces) {
@@ -178,23 +247,13 @@ export class MemoryStore {
* (estimated at 4 characters per token).
*/
getContextForPrompt(): string {
const userMemory = this.read('user');
const globalMemory = this.read('global');
const sections = this.getPromptSections().map((section) => `## ${section.title}\n\n${section.content}`);
// Nothing to inject
if (userMemory.length === 0 && globalMemory.length === 0) {
if (sections.length === 0) {
return '';
}
const sections: string[] = [];
if (userMemory.length > 0) {
sections.push(`## User Memory\n\n${userMemory}`);
}
if (globalMemory.length > 0) {
sections.push(`## Global Memory\n\n${globalMemory}`);
}
const full = sections.join('\n\n');
// Truncate to fit within the token budget (estimate: 4 chars ≈ 1 token)
@@ -205,6 +264,45 @@ export class MemoryStore {
return full.slice(0, maxChars);
}
/** Build memory sections used by prompt injectors. */
getPromptSections(): PromptMemorySection[] {
const userMemory = this.read('user');
const globalMemory = this.read('global');
const userCategoryMemory = this.readAllCategories('user');
const globalCategoryMemory = this.readAllCategories('global');
const sections: PromptMemorySection[] = [];
if (userMemory.length > 0) {
sections.push({ title: 'User Memory', content: userMemory });
}
if (globalMemory.length > 0) {
sections.push({ title: 'Global Memory', content: globalMemory });
}
for (const category of MEMORY_CATEGORIES) {
const content = userCategoryMemory[category];
if (content) {
sections.push({
title: `User ${this._categoryLabel(category)}`,
content,
});
}
}
for (const category of MEMORY_CATEGORIES) {
const content = globalCategoryMemory[category];
if (content) {
sections.push({
title: `Global ${this._categoryLabel(category)}`,
content,
});
}
}
return sections;
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
@@ -236,4 +334,8 @@ export class MemoryStore {
return namespaces;
}
private _categoryLabel(category: MemoryCategory): string {
return `${category.charAt(0).toUpperCase()}${category.slice(1)}`;
}
}