diff --git a/src/lib/config-manager.test.ts b/src/lib/config-manager.test.ts index a84e8ca1..9fb40df4 100644 --- a/src/lib/config-manager.test.ts +++ b/src/lib/config-manager.test.ts @@ -38,6 +38,9 @@ describe('config-manager', () => { setConfigValue(vaultPath, 'name', 'clawvault-dev'); setConfigValue(vaultPath, 'categories', 'people,projects,decisions'); setConfigValue(vaultPath, 'theme', 'minimal'); + setConfigValue(vaultPath, 'models.background', 'gpt-4o-mini'); + setConfigValue(vaultPath, 'models.default', 'gpt-4.1'); + setConfigValue(vaultPath, 'models.complex', 'gpt-5'); setConfigValue(vaultPath, 'observe.provider', 'openai'); setConfigValue(vaultPath, 'observe.model', 'gpt-5-mini'); setConfigValue(vaultPath, 'observer.compression.provider', 'openai-compatible'); @@ -67,6 +70,9 @@ describe('config-manager', () => { expect(getConfigValue(vaultPath, 'name')).toBe('clawvault-dev'); expect(getConfigValue(vaultPath, 'categories')).toEqual(['people', 'projects', 'decisions']); expect(getConfigValue(vaultPath, 'theme')).toBe('minimal'); + expect(getConfigValue(vaultPath, 'models.background')).toBe('gpt-4o-mini'); + expect(getConfigValue(vaultPath, 'models.default')).toBe('gpt-4.1'); + expect(getConfigValue(vaultPath, 'models.complex')).toBe('gpt-5'); expect(getConfigValue(vaultPath, 'observe.provider')).toBe('openai'); expect(getConfigValue(vaultPath, 'observe.model')).toBe('gpt-5-mini'); expect(getConfigValue(vaultPath, 'observer.compression.provider')).toBe('openai-compatible'); @@ -92,6 +98,11 @@ describe('config-manager', () => { name: 'clawvault-dev', categories: ['people', 'projects', 'decisions'], theme: 'minimal', + models: { + background: 'gpt-4o-mini', + default: 'gpt-4.1', + complex: 'gpt-5' + }, observe: { provider: 'openai', model: 'gpt-5-mini' @@ -147,6 +158,9 @@ describe('config-manager', () => { setConfigValue(vaultPath, 'name', 'custom-name'); setConfigValue(vaultPath, 'categories', 'custom-a,custom-b'); setConfigValue(vaultPath, 'theme', 'neural'); + setConfigValue(vaultPath, 'models.background', 'gpt-4o-mini'); + setConfigValue(vaultPath, 'models.default', 'gpt-4.1'); + setConfigValue(vaultPath, 'models.complex', 'gpt-5'); setConfigValue(vaultPath, 'observer.compression.provider', 'ollama'); setConfigValue(vaultPath, 'observer.compression.model', 'llama3.2:latest'); addRouteRule(vaultPath, 'Pedro', 'people/pedro'); @@ -156,6 +170,7 @@ describe('config-manager', () => { name: path.basename(vaultPath), categories: DEFAULT_CATEGORIES, theme: 'none', + models: {}, observe: { provider: 'gemini', model: 'gemini-2.0-flash' diff --git a/src/lib/config-manager.ts b/src/lib/config-manager.ts index d8e54170..7ef7eb2e 100644 --- a/src/lib/config-manager.ts +++ b/src/lib/config-manager.ts @@ -21,6 +21,7 @@ const FACT_EXTRACTION_MODES = ['off', 'rule', 'llm', 'hybrid'] as const; const SEARCH_BACKENDS = ['in-process', 'qmd'] as const; const SEARCH_EMBEDDING_PROVIDERS = ['none', 'openai', 'gemini', 'ollama'] as const; const SEARCH_RERANK_PROVIDERS = ['none', 'jina', 'voyage', 'siliconflow', 'pinecone'] as const; +const MODEL_TIERS = ['background', 'default', 'complex'] as const; export type ObserveProvider = (typeof OBSERVE_PROVIDERS)[number]; export type ObserverCompressionProvider = (typeof OBSERVER_COMPRESSION_PROVIDERS)[number]; @@ -30,10 +31,14 @@ export type FactExtractionMode = (typeof FACT_EXTRACTION_MODES)[number]; export type SearchBackend = (typeof SEARCH_BACKENDS)[number]; export type SearchEmbeddingProvider = (typeof SEARCH_EMBEDDING_PROVIDERS)[number]; export type SearchRerankProvider = (typeof SEARCH_RERANK_PROVIDERS)[number]; +export type ModelTier = (typeof MODEL_TIERS)[number]; export type ManagedConfigKey = | 'name' | 'categories' | 'theme' + | 'models.background' + | 'models.default' + | 'models.complex' | 'observe.model' | 'observe.provider' | 'observer.compression.provider' @@ -71,6 +76,11 @@ export interface ManagedDefaults { name: string; categories: string[]; theme: Theme; + models: { + background?: string; + default?: string; + complex?: string; + }; observe: { model: string; provider: ObserveProvider; @@ -122,6 +132,9 @@ export const SUPPORTED_CONFIG_KEYS: ManagedConfigKey[] = [ 'name', 'categories', 'theme', + 'models.background', + 'models.default', + 'models.complex', 'observe.model', 'observe.provider', 'observer.compression.provider', @@ -370,6 +383,7 @@ function withDefaults(vaultPath: string, config: Record): Recor name: path.basename(resolvedPath), categories: [...DEFAULT_CATEGORIES], theme: DEFAULT_THEME, + models: {}, observe: { model: DEFAULT_OBSERVE_MODEL, provider: DEFAULT_OBSERVE_PROVIDER @@ -411,6 +425,18 @@ function withDefaults(vaultPath: string, config: Record): Recor ? config.observe : {} ) as Record; + const modelsRecord = ( + config.models && typeof config.models === 'object' && !Array.isArray(config.models) + ? config.models + : {} + ) as Record; + const normalizedModels: ManagedDefaults['models'] = {}; + for (const tier of MODEL_TIERS) { + const candidate = modelsRecord[tier]; + if (typeof candidate === 'string' && candidate.trim()) { + normalizedModels[tier] = candidate.trim(); + } + } const contextRecord = ( config.context && typeof config.context === 'object' && !Array.isArray(config.context) ? config.context @@ -483,6 +509,7 @@ function withDefaults(vaultPath: string, config: Record): Recor name: typeof config.name === 'string' && config.name.trim() ? config.name.trim() : defaults.name, categories: asStringArray(config.categories) ?? defaults.categories, theme: isTheme(config.theme) ? config.theme : defaults.theme, + models: normalizedModels, observe: { ...observeRecord, model: typeof observeRecord.model === 'string' && observeRecord.model.trim() @@ -610,6 +637,13 @@ function coerceManagedValue(key: ManagedConfigKey, value: unknown): unknown { return value; } + if (key === 'models.background' || key === 'models.default' || key === 'models.complex') { + if (typeof value !== 'string' || !value.trim()) { + throw new Error(`Config key "${key}" must be a non-empty string.`); + } + return value.trim(); + } + if (key === 'observe.provider') { if (!isObserveProvider(value)) { throw new Error(`Config key "observe.provider" must be one of: ${OBSERVE_PROVIDERS.join(', ')}`); @@ -833,6 +867,7 @@ export function resetConfig(vaultPath: string): Record { document.name = defaultName; document.categories = [...DEFAULT_CATEGORIES]; document.theme = DEFAULT_THEME; + document.models = {}; document.observe = { model: DEFAULT_OBSERVE_MODEL, provider: DEFAULT_OBSERVE_PROVIDER diff --git a/src/observer/observer.test.ts b/src/observer/observer.test.ts index 48cc1e48..4d4dd018 100644 --- a/src/observer/observer.test.ts +++ b/src/observer/observer.test.ts @@ -217,6 +217,63 @@ describe('Observer', () => { } }); + it('uses models.background for compression when compression model is not set', async () => { + const vaultPath = makeTempVault(); + const now = withFixedNow('2026-02-11T16:05:00.000Z'); + const configPath = path.join(vaultPath, '.clawvault.json'); + const config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record; + config.models = { + background: 'cheap-background-model', + default: 'default-model', + complex: 'complex-model' + }; + config.observer = { + compression: { + provider: 'openai-compatible', + baseUrl: 'http://localhost:11434/v1' + }, + factExtractionMode: 'off' + }; + fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8'); + + process.env.ANTHROPIC_API_KEY = ''; + process.env.OPENAI_API_KEY = ''; + process.env.GEMINI_API_KEY = ''; + + const fetchSpy = vi.fn(async (_input: unknown, _init?: RequestInit) => ({ + ok: true, + json: async () => ({ + choices: [ + { + message: { + content: '## 2026-02-11\n\n- [fact|c=0.80|i=0.40] 16:05 Background tier selected' + } + } + ] + }) + } as Response)); + vi.stubGlobal('fetch', fetchSpy as unknown as typeof fetch); + + try { + const observer = new Observer(vaultPath, { + tokenThreshold: 1, + reflectThreshold: 99999, + now + }); + await observer.processMessages(['capture state']); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, request] = fetchSpy.mock.calls[0] as [unknown, RequestInit]; + const requestUrl = typeof url === 'string' ? url : String(url); + const body = JSON.parse(String(request.body)) as { model?: string }; + expect(requestUrl).toBe('http://localhost:11434/v1/chat/completions'); + expect(body.model).toBe('cheap-background-model'); + expect(observer.getObservations()).toContain('Background tier selected'); + } finally { + fs.rmSync(vaultPath, { recursive: true, force: true }); + } + }); + it('falls back to env-based provider when configured provider lacks required credentials', async () => { const vaultPath = makeTempVault(); const now = withFixedNow('2026-02-11T16:10:00.000Z'); diff --git a/src/observer/observer.ts b/src/observer/observer.ts index bba1a326..081d7d9c 100644 --- a/src/observer/observer.ts +++ b/src/observer/observer.ts @@ -92,14 +92,13 @@ function readCompressionConfig(vaultPath: string): CompressionConfigSnapshot { const root = asRecord(config); const observer = asRecord(root?.observer); const compression = asRecord(observer?.compression); - if (!compression) { - return {}; - } + const models = asRecord(root?.models); + const backgroundTierModel = asNonEmptyString(models?.background); return { - provider: asCompressionProvider(compression.provider), - model: asNonEmptyString(compression.model), - baseUrl: asNonEmptyString(compression.baseUrl), - apiKey: asNonEmptyString(compression.apiKey) + provider: asCompressionProvider(compression?.provider), + model: asNonEmptyString(compression?.model) ?? backgroundTierModel, + baseUrl: asNonEmptyString(compression?.baseUrl), + apiKey: asNonEmptyString(compression?.apiKey) }; } catch { return {};