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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/cli/commands/init/ingest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,9 @@ export async function offerBatchIngest(): Promise<void> {
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,
Expand Down
25 changes: 25 additions & 0 deletions src/config/bootstrap.ts
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@

export * from './memory-config.js';
export * from './loader.js';
export * from './bootstrap.js';
8 changes: 8 additions & 0 deletions src/config/memory-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 2 additions & 3 deletions src/dashboard/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,8 @@ export function createApp() {

export async function startDashboard(port: number): Promise<void> {
// 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();
Expand Down
6 changes: 3 additions & 3 deletions src/hooks/session-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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)
Expand Down
5 changes: 2 additions & 3 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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' });
Expand Down
61 changes: 61 additions & 0 deletions test/config/bootstrap.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
74 changes: 74 additions & 0 deletions test/config/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
Loading