diff --git a/CHANGELOG.md b/CHANGELOG.md index 75fd7ce..b20d045 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.10.2] - 2026-03-13 + +### Added + +- **Shared `bootstrap()` function** (`src/config/bootstrap.ts`): Single entry point for configuration initialization — calls `loadConfig()` → `toRuntimeConfig()` → `initRuntimeConfig()`, returns the resolved config, idempotent. Eliminates the recurring pattern where entry points independently chain these calls (or forget to). +- **`resetRuntimeConfig()`** in `memory-config.ts`: Test-only function to clear the runtime config cache. + +### Changed + +- **Entry point config initialization**: All 4 entry points (`src/mcp/server.ts`, `src/dashboard/server.ts`, `src/hooks/session-start.ts`, `src/cli/commands/init/ingest.ts`) now use `bootstrap()` instead of inline `initRuntimeConfig(toRuntimeConfig(loadConfig()))`. The ingest command was also missing `initRuntimeConfig()` entirely — `getConfig()` would have returned bare defaults instead of user config. +- **SECURITY.md**: Updated supported versions to `>= 0.10.2`. + +### Tests + +- 32 new tests across 3 new/updated test files: + - `test/config/memory-config.test.ts` (13 tests): `initRuntimeConfig`/`getConfig` cache lifecycle, deep-merge for all 7 nested config objects, override immutability, idempotency. + - `test/config/loader.test.ts` (+15 tests): Empty string env vars, NaN handling, `clusterHour` range validation (−1, 0, 12, 23, 24), `halfLifeHours` validation (−1, 0, 48), `decayFactor` validation (−0.1, 0, 0.95). + - `test/config/bootstrap.test.ts` (4 tests): Config resolution, idempotency, return value, CLI override passthrough. +- 2589 tests passing. + ## [0.10.1] - 2026-03-13 ### Added diff --git a/SECURITY.md b/SECURITY.md index d9b0f97..068fc55 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,8 +4,8 @@ | Version | Supported | | ------- | ------------------ | -| >= 0.9.0 | :white_check_mark: | -| < 0.9.0 | :x: | +| >= 0.10.2 | :white_check_mark: | +| < 0.10.2 | :x: | ## Reporting a Vulnerability diff --git a/package.json b/package.json index f66c62f..62012a3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "causantic", - "version": "0.10.1", + "version": "0.10.2", "description": "Long-term memory for Claude Code — local-first, graph-augmented, self-benchmarking", "type": "module", "private": false, diff --git a/src/cli/commands/init/ingest.ts b/src/cli/commands/init/ingest.ts index 4e0c0c8..a99de66 100644 --- a/src/cli/commands/init/ingest.ts +++ b/src/cli/commands/init/ingest.ts @@ -104,9 +104,9 @@ export async function offerBatchIngest(): Promise { const { discoverSessions, batchIngest } = await import('../../../ingest/batch-ingest.js'); const { Embedder } = await import('../../../models/embedder.js'); const { getModel } = await import('../../../models/model-registry.js'); - const { loadConfig, toRuntimeConfig } = await import('../../../config/loader.js'); + const { bootstrap } = await import('../../../config/bootstrap.js'); - const runtimeConfig = toRuntimeConfig(loadConfig()); + const runtimeConfig = bootstrap(); const sharedEmbedder = new Embedder(); await sharedEmbedder.load(getModel(runtimeConfig.embeddingModel), { device: detectedDevice.device, diff --git a/src/config/bootstrap.ts b/src/config/bootstrap.ts new file mode 100644 index 0000000..8f24704 --- /dev/null +++ b/src/config/bootstrap.ts @@ -0,0 +1,25 @@ +/** + * Shared bootstrap function for all entry points. + * + * Ensures user config files and env vars are loaded into the + * runtime config cache exactly once. Idempotent — safe to call + * multiple times (last call wins). + */ + +import { initRuntimeConfig, type MemoryConfig } from './memory-config.js'; +import { loadConfig, toRuntimeConfig, type LoadConfigOptions } from './loader.js'; + +/** + * Load configuration from all sources and initialize the runtime cache. + * + * Call this once at startup in every entry point (MCP server, dashboard, + * hooks, CLI commands) instead of manually chaining + * `initRuntimeConfig(toRuntimeConfig(loadConfig()))`. + * + * @returns The resolved MemoryConfig for callers that need it directly. + */ +export function bootstrap(options?: LoadConfigOptions): MemoryConfig { + const config = toRuntimeConfig(loadConfig(options)); + initRuntimeConfig(config); + return config; +} diff --git a/src/config/index.ts b/src/config/index.ts index 3f8ec52..dd179c4 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -4,3 +4,4 @@ export * from './memory-config.js'; export * from './loader.js'; +export * from './bootstrap.js'; diff --git a/src/config/memory-config.ts b/src/config/memory-config.ts index 6c98543..9c87179 100644 --- a/src/config/memory-config.ts +++ b/src/config/memory-config.ts @@ -240,6 +240,14 @@ export function initRuntimeConfig(config: MemoryConfig): void { _runtimeConfig = config; } +/** + * Reset the runtime config cache (test-only). + * Reverts getConfig() to returning DEFAULT_CONFIG. + */ +export function resetRuntimeConfig(): void { + _runtimeConfig = null; +} + /** * Get configuration with overrides applied. * Returns the cached runtime config (from initRuntimeConfig) if available, diff --git a/src/dashboard/server.ts b/src/dashboard/server.ts index 0bec13c..f2853c8 100644 --- a/src/dashboard/server.ts +++ b/src/dashboard/server.ts @@ -59,9 +59,8 @@ export function createApp() { export async function startDashboard(port: number): Promise { // Ensure config and database are initialized before starting - const { initRuntimeConfig } = await import('../config/memory-config.js'); - const { loadConfig, toRuntimeConfig } = await import('../config/loader.js'); - initRuntimeConfig(toRuntimeConfig(loadConfig())); + const { bootstrap } = await import('../config/bootstrap.js'); + bootstrap(); const { getDb } = await import('../storage/db.js'); getDb(); diff --git a/src/hooks/session-start.ts b/src/hooks/session-start.ts index ee5873a..e1ac401 100644 --- a/src/hooks/session-start.ts +++ b/src/hooks/session-start.ts @@ -20,8 +20,8 @@ import { getSessionsForProject, getChunksByTimeRange, } from '../storage/chunk-store.js'; -import { getConfig, initRuntimeConfig } from '../config/memory-config.js'; -import { loadConfig, toRuntimeConfig } from '../config/loader.js'; +import { getConfig } from '../config/memory-config.js'; +import { bootstrap } from '../config/bootstrap.js'; import { approximateTokens } from '../utils/token-counter.js'; import { runStaleMaintenanceTasks } from '../maintenance/scheduler.js'; import { executeHook, logHook, isTransientError, type HookMetrics } from './hook-utils.js'; @@ -195,7 +195,7 @@ export async function handleSessionStart( const { enableRetry = true, maxRetries = 3, gracefulDegradation = true } = options; // Ensure user config is loaded before getConfig() is used - initRuntimeConfig(toRuntimeConfig(loadConfig())); + bootstrap(); // Run stale maintenance tasks in background (prune, recluster) // Covers cases where scheduled cron times were missed (e.g. laptop asleep) diff --git a/src/mcp/server.ts b/src/mcp/server.ts index f2219df..75e7df7 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -13,8 +13,7 @@ import { createInterface } from 'readline'; import { tools, getTool } from './tools.js'; import { getDb, closeDb } from '../storage/db.js'; -import { initRuntimeConfig } from '../config/memory-config.js'; -import { loadConfig, toRuntimeConfig } from '../config/loader.js'; +import { bootstrap } from '../config/bootstrap.js'; import { disposeRetrieval } from '../retrieval/context-assembler.js'; import { getChunkCount } from '../storage/chunk-store.js'; import { getEdgeCount } from '../storage/edge-store.js'; @@ -179,7 +178,7 @@ export class McpServer { this.startTime = Date.now(); // Initialize config and database - initRuntimeConfig(toRuntimeConfig(loadConfig())); + bootstrap(); getDb(); this.log({ level: 'info', event: 'server_started' }); diff --git a/test/config/bootstrap.test.ts b/test/config/bootstrap.test.ts new file mode 100644 index 0000000..b5b5575 --- /dev/null +++ b/test/config/bootstrap.test.ts @@ -0,0 +1,61 @@ +/** + * Tests for the shared bootstrap() function. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { bootstrap } from '../../src/config/bootstrap.js'; +import { getConfig, resetRuntimeConfig, DEFAULT_CONFIG } from '../../src/config/memory-config.js'; + +describe('bootstrap', () => { + beforeEach(() => { + resetRuntimeConfig(); + }); + + it('makes getConfig() return user config instead of bare defaults', () => { + // Before bootstrap, getConfig returns DEFAULT_CONFIG + expect(getConfig()).toBe(DEFAULT_CONFIG); + + bootstrap({ skipProjectConfig: true, skipUserConfig: true, skipEnv: true }); + + // After bootstrap, getConfig returns a resolved config (not the DEFAULT_CONFIG reference) + const config = getConfig(); + // Values match defaults but the object is different (runtime config was set) + expect(config.clusterThreshold).toBe(DEFAULT_CONFIG.clusterThreshold); + expect(config).not.toBe(DEFAULT_CONFIG); + }); + + it('idempotent: second call does not throw', () => { + const opts = { skipProjectConfig: true, skipUserConfig: true, skipEnv: true }; + + expect(() => { + bootstrap(opts); + bootstrap(opts); + }).not.toThrow(); + }); + + it('returns the resolved MemoryConfig', () => { + const config = bootstrap({ + skipProjectConfig: true, + skipUserConfig: true, + skipEnv: true, + }); + + expect(config.clusterThreshold).toBe(DEFAULT_CONFIG.clusterThreshold); + expect(config.maxChainDepth).toBe(DEFAULT_CONFIG.maxChainDepth); + expect(config.hybridSearch).toBeDefined(); + }); + + it('respects CLI overrides passed through options', () => { + const config = bootstrap({ + skipProjectConfig: true, + skipUserConfig: true, + skipEnv: true, + cliOverrides: { + traversal: { maxDepth: 10 }, + }, + }); + + expect(config.maxChainDepth).toBe(10); + expect(getConfig().maxChainDepth).toBe(10); + }); +}); diff --git a/test/config/loader.test.ts b/test/config/loader.test.ts index 6e059e3..ba4dd73 100644 --- a/test/config/loader.test.ts +++ b/test/config/loader.test.ts @@ -461,6 +461,80 @@ describe('loadConfig', () => { // Non-numeric values are skipped, default is preserved expect(config.clustering.threshold).toBe(0.1); }); + + it('ignores empty string env var (keeps default)', () => { + process.env.CAUSANTIC_CLUSTERING_THRESHOLD = ''; + + const config = loadConfig({ + skipProjectConfig: true, + skipUserConfig: true, + }); + + // Empty string produces NaN for float → skipped + expect(config.clustering.threshold).toBe(0.1); + }); + + it('ignores empty string for integer env var (keeps default)', () => { + process.env.CAUSANTIC_CLUSTERING_MIN_CLUSTER_SIZE = ''; + + const config = loadConfig({ + skipProjectConfig: true, + skipUserConfig: true, + }); + + expect(config.clustering.minClusterSize).toBe(4); + }); + }); + + describe('validation-guarded env overrides', () => { + it('rejects clusterHour = -1', () => { + const errors = validateExternalConfig({ maintenance: { clusterHour: -1 } }); + expect(errors).toContain('maintenance.clusterHour must be between 0 and 23 (inclusive)'); + }); + + it('rejects clusterHour = 24', () => { + const errors = validateExternalConfig({ maintenance: { clusterHour: 24 } }); + expect(errors).toContain('maintenance.clusterHour must be between 0 and 23 (inclusive)'); + }); + + it('accepts clusterHour = 0', () => { + expect(validateExternalConfig({ maintenance: { clusterHour: 0 } })).toEqual([]); + }); + + it('accepts clusterHour = 12', () => { + expect(validateExternalConfig({ maintenance: { clusterHour: 12 } })).toEqual([]); + }); + + it('accepts clusterHour = 23', () => { + expect(validateExternalConfig({ maintenance: { clusterHour: 23 } })).toEqual([]); + }); + + it('rejects halfLifeHours = 0', () => { + const errors = validateExternalConfig({ recency: { halfLifeHours: 0 } }); + expect(errors).toContain('recency.halfLifeHours must be greater than 0'); + }); + + it('rejects halfLifeHours = -1', () => { + const errors = validateExternalConfig({ recency: { halfLifeHours: -1 } }); + expect(errors).toContain('recency.halfLifeHours must be greater than 0'); + }); + + it('accepts halfLifeHours = 48', () => { + expect(validateExternalConfig({ recency: { halfLifeHours: 48 } })).toEqual([]); + }); + + it('rejects decayFactor = -0.1', () => { + const errors = validateExternalConfig({ recency: { decayFactor: -0.1 } }); + expect(errors).toContain('recency.decayFactor must be >= 0'); + }); + + it('accepts decayFactor = 0', () => { + expect(validateExternalConfig({ recency: { decayFactor: 0 } })).toEqual([]); + }); + + it('accepts decayFactor = 0.95', () => { + expect(validateExternalConfig({ recency: { decayFactor: 0.95 } })).toEqual([]); + }); }); describe('CLI overrides (highest priority)', () => { diff --git a/test/config/memory-config.test.ts b/test/config/memory-config.test.ts new file mode 100644 index 0000000..aa8154d --- /dev/null +++ b/test/config/memory-config.test.ts @@ -0,0 +1,148 @@ +/** + * Tests for initRuntimeConfig / getConfig cache lifecycle and deep-merge overrides. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { + getConfig, + initRuntimeConfig, + resetRuntimeConfig, + DEFAULT_CONFIG, + type MemoryConfig, +} from '../../src/config/memory-config.js'; + +describe('initRuntimeConfig / getConfig cache', () => { + beforeEach(() => { + resetRuntimeConfig(); + }); + + it('getConfig() returns defaults when no runtime config set', () => { + const config = getConfig(); + expect(config).toBe(DEFAULT_CONFIG); + }); + + it('initRuntimeConfig() sets the cached config', () => { + const custom: MemoryConfig = { + ...DEFAULT_CONFIG, + maxChainDepth: 99, + }; + initRuntimeConfig(custom); + + const config = getConfig(); + expect(config.maxChainDepth).toBe(99); + }); + + it('getConfig() returns cached config after init', () => { + const custom: MemoryConfig = { + ...DEFAULT_CONFIG, + clusterThreshold: 0.25, + }; + initRuntimeConfig(custom); + + expect(getConfig()).toBe(custom); + }); + + it('getConfig(overrides) deep-merges nested objects correctly', () => { + initRuntimeConfig({ + ...DEFAULT_CONFIG, + hybridSearch: { ...DEFAULT_CONFIG.hybridSearch, rrfK: 100 }, + }); + + // Only override vectorWeight — rrfK should come from the cached base + const config = getConfig({ + hybridSearch: { vectorWeight: 2.0 } as MemoryConfig['hybridSearch'], + }); + + // Override applied + expect(config.hybridSearch.vectorWeight).toBe(2.0); + // Base value preserved via deep merge + expect(config.hybridSearch.rrfK).toBe(100); + }); + + it('deep-merges clusterExpansion overrides', () => { + initRuntimeConfig(DEFAULT_CONFIG); + + const config = getConfig({ + clusterExpansion: { ...DEFAULT_CONFIG.clusterExpansion, maxClusters: 10 }, + }); + + expect(config.clusterExpansion.maxClusters).toBe(10); + expect(config.clusterExpansion.maxSiblings).toBe(DEFAULT_CONFIG.clusterExpansion.maxSiblings); + }); + + it('deep-merges mmrReranking overrides', () => { + initRuntimeConfig(DEFAULT_CONFIG); + + const config = getConfig({ mmrReranking: { lambda: 0.3 } }); + expect(config.mmrReranking.lambda).toBe(0.3); + }); + + it('deep-merges recency overrides', () => { + initRuntimeConfig(DEFAULT_CONFIG); + + const config = getConfig({ recency: { ...DEFAULT_CONFIG.recency, halfLifeHours: 12 } }); + + expect(config.recency.halfLifeHours).toBe(12); + expect(config.recency.decayFactor).toBe(DEFAULT_CONFIG.recency.decayFactor); + }); + + it('deep-merges lengthPenalty overrides', () => { + initRuntimeConfig(DEFAULT_CONFIG); + + const config = getConfig({ + lengthPenalty: { ...DEFAULT_CONFIG.lengthPenalty, referenceTokens: 1000 }, + }); + + expect(config.lengthPenalty.referenceTokens).toBe(1000); + expect(config.lengthPenalty.enabled).toBe(DEFAULT_CONFIG.lengthPenalty.enabled); + }); + + it('deep-merges repomap overrides (preserves languages array)', () => { + initRuntimeConfig(DEFAULT_CONFIG); + + const config = getConfig({ repomap: { ...DEFAULT_CONFIG.repomap, maxTokens: 2048 } }); + + expect(config.repomap.maxTokens).toBe(2048); + expect(config.repomap.languages).toEqual(DEFAULT_CONFIG.repomap.languages); + }); + + it('deep-merges semanticIndex overrides', () => { + initRuntimeConfig(DEFAULT_CONFIG); + + const config = getConfig({ + semanticIndex: { ...DEFAULT_CONFIG.semanticIndex, batchRefreshLimit: 100 }, + }); + + expect(config.semanticIndex.batchRefreshLimit).toBe(100); + expect(config.semanticIndex.enabled).toBe(DEFAULT_CONFIG.semanticIndex.enabled); + }); + + it('overrides do not mutate the cached config', () => { + const custom: MemoryConfig = { + ...DEFAULT_CONFIG, + maxChainDepth: 42, + hybridSearch: { ...DEFAULT_CONFIG.hybridSearch }, + }; + initRuntimeConfig(custom); + + // Apply override + getConfig({ maxChainDepth: 999 }); + + // Cached config unchanged + expect(getConfig().maxChainDepth).toBe(42); + }); + + it('idempotent: calling initRuntimeConfig() twice uses the latest', () => { + initRuntimeConfig({ ...DEFAULT_CONFIG, maxChainDepth: 10 }); + initRuntimeConfig({ ...DEFAULT_CONFIG, maxChainDepth: 20 }); + + expect(getConfig().maxChainDepth).toBe(20); + }); + + it('getConfig() with empty overrides returns base without copying', () => { + initRuntimeConfig(DEFAULT_CONFIG); + + const config = getConfig({}); + expect(config).toBe(DEFAULT_CONFIG); + }); +});