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:
@@ -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';
|
||||
|
||||
@@ -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
@@ -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)}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user