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