Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/lib/config-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand All @@ -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'
Expand Down Expand Up @@ -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');
Expand All @@ -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'
Expand Down
35 changes: 35 additions & 0 deletions src/lib/config-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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];
Expand All @@ -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'
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -370,6 +383,7 @@ function withDefaults(vaultPath: string, config: Record<string, unknown>): Recor
name: path.basename(resolvedPath),
categories: [...DEFAULT_CATEGORIES],
theme: DEFAULT_THEME,
models: {},
observe: {
model: DEFAULT_OBSERVE_MODEL,
provider: DEFAULT_OBSERVE_PROVIDER
Expand Down Expand Up @@ -411,6 +425,18 @@ function withDefaults(vaultPath: string, config: Record<string, unknown>): Recor
? config.observe
: {}
) as Record<string, unknown>;
const modelsRecord = (
config.models && typeof config.models === 'object' && !Array.isArray(config.models)
? config.models
: {}
) as Record<string, unknown>;
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
Expand Down Expand Up @@ -483,6 +509,7 @@ function withDefaults(vaultPath: string, config: Record<string, unknown>): 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()
Expand Down Expand Up @@ -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(', ')}`);
Expand Down Expand Up @@ -833,6 +867,7 @@ export function resetConfig(vaultPath: string): Record<string, unknown> {
document.name = defaultName;
document.categories = [...DEFAULT_CATEGORIES];
document.theme = DEFAULT_THEME;
document.models = {};
document.observe = {
model: DEFAULT_OBSERVE_MODEL,
provider: DEFAULT_OBSERVE_PROVIDER
Expand Down
57 changes: 57 additions & 0 deletions src/observer/observer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
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');
Expand Down
13 changes: 6 additions & 7 deletions src/observer/observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {};
Expand Down
Loading