kthulu/codebase/@packages/agent-core/src/settings-loader.ts
Lilith 16a5c1d42b fix(agent-core): 🐛 Fix settings loader validation and processing logic for consistent behavior
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-03-13 18:09:30 -07:00

280 lines
9.4 KiB
TypeScript

import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { homedir } from 'node:os';
export interface KthuluSettings {
// Model
model?: string;
temperature?: number;
maxTokens?: number;
contextLength?: number;
thinking?: boolean;
// Agent behavior
maxTurns?: number;
effort?: 'low' | 'medium' | 'high';
autoApprove?: boolean;
// Token budget
maxSessionTokens?: number;
budgetWarnThreshold?: number;
// Tools
allowedTools?: string[];
disallowedTools?: string[];
// Hooks
hooks?: Record<string, string[]>;
// Display
verbose?: boolean;
theme?: 'default' | 'minimal' | 'verbose';
}
/** All fields present — we guarantee defaults are always defined. */
type ResolvedSettings = Required<Omit<KthuluSettings, 'model' | 'hooks' | 'allowedTools' | 'disallowedTools'>> &
Pick<KthuluSettings, 'model' | 'hooks' | 'allowedTools' | 'disallowedTools'>;
function envInt(key: string, fallback: number): number {
const val = process.env[key];
if (val === undefined) return fallback;
const parsed = parseInt(val, 10);
return Number.isNaN(parsed) ? fallback : parsed;
}
function envFloat(key: string, fallback: number): number {
const val = process.env[key];
if (val === undefined) return fallback;
const parsed = parseFloat(val);
return Number.isNaN(parsed) ? fallback : parsed;
}
function envBool(key: string, fallback: boolean): boolean {
const val = process.env[key];
if (val === undefined) return fallback;
return val === '1' || val === 'true';
}
function envString<T extends string>(key: string, fallback: T, valid?: T[]): T {
const val = process.env[key] as T | undefined;
if (val === undefined) return fallback;
if (valid && !valid.includes(val)) return fallback;
return val;
}
const BUILT_IN_DEFAULTS: ResolvedSettings = {
// model is intentionally absent — callers supply a fallback
model: process.env['KTHULU_MODEL'],
temperature: envFloat('KTHULU_TEMPERATURE', 0.6),
maxTokens: envInt('KTHULU_MAX_TOKENS', 16384),
contextLength: envInt('KTHULU_CONTEXT_LENGTH', 32768),
thinking: envBool('KTHULU_THINKING', false),
maxTurns: envInt('KTHULU_MAX_TURNS', 20),
effort: envString('KTHULU_EFFORT', 'medium', ['low', 'medium', 'high']),
autoApprove: envBool('KTHULU_AUTO_APPROVE', false),
maxSessionTokens: envInt('KTHULU_MAX_SESSION_TOKENS', 0),
budgetWarnThreshold: envFloat('KTHULU_BUDGET_WARN_THRESHOLD', 0.8),
allowedTools: undefined,
disallowedTools: undefined,
hooks: undefined,
verbose: envBool('KTHULU_VERBOSE', false),
theme: envString('KTHULU_THEME', 'default', ['default', 'minimal', 'verbose']),
};
/**
* Loads KthuluSettings from a 4-tier hierarchy, each layer overriding the
* previous. We apply them in this order:
*
* 1. Built-in defaults (hardcoded above)
* 2. User settings — ~/.kthulu/settings.json
* 3. Project settings — <projectDir>/.kthulu/settings.json
* 4. Local overrides — <projectDir>/.kthulu/settings.local.json (gitignored)
*/
export class SettingsLoader {
private readonly userConfigDir: string;
constructor(userConfigDir?: string) {
this.userConfigDir = userConfigDir ?? join(homedir(), '.kthulu');
}
async load(projectDir: string): Promise<KthuluSettings> {
const [userSettings, projectSettings, localOverrides] = await Promise.all([
this.loadJsonFile(join(this.userConfigDir, 'settings.json')),
this.loadJsonFile(join(projectDir, '.kthulu', 'settings.json')),
this.loadJsonFile(join(projectDir, '.kthulu', 'settings.local.json')),
]);
let merged: KthuluSettings = { ...BUILT_IN_DEFAULTS };
merged = this.deepMerge(merged, userSettings);
merged = this.deepMerge(merged, projectSettings);
merged = this.deepMerge(merged, localOverrides);
return merged;
}
/**
* Reads and parses a JSON settings file. Returns an empty object when the
* file is absent or cannot be parsed so that we never abort the load chain
* due to a missing optional file.
*/
private async loadJsonFile(filePath: string): Promise<Partial<KthuluSettings>> {
let raw: string;
try {
raw = await readFile(filePath, 'utf-8');
} catch {
// File does not exist or is unreadable — treat as empty layer.
return {};
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
// Malformed JSON — we surface a warning but do not abort.
process.stderr.write(`[kthulu] Warning: settings file at "${filePath}" contains invalid JSON and will be ignored.\n`);
return {};
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
process.stderr.write(`[kthulu] Warning: settings file at "${filePath}" must be a JSON object and will be ignored.\n`);
return {};
}
return this.validateShape(parsed as Record<string, unknown>, filePath);
}
/**
* Strips unknown keys and validates types for known fields. Unknown keys
* are silently dropped so that future settings additions remain backwards
* compatible.
*/
private validateShape(
raw: Record<string, unknown>,
filePath: string,
): Partial<KthuluSettings> {
const out: Partial<KthuluSettings> = {};
const warn = (field: string, expected: string) =>
process.stderr.write(
`[kthulu] Warning: "${field}" in "${filePath}" must be ${expected} — ignoring field.\n`,
);
if ('model' in raw) {
if (typeof raw['model'] === 'string') out.model = raw['model'];
else warn('model', 'a string');
}
if ('temperature' in raw) {
if (typeof raw['temperature'] === 'number') out.temperature = raw['temperature'];
else warn('temperature', 'a number');
}
if ('maxTokens' in raw) {
if (typeof raw['maxTokens'] === 'number') out.maxTokens = raw['maxTokens'];
else warn('maxTokens', 'a number');
}
if ('contextLength' in raw) {
if (typeof raw['contextLength'] === 'number') out.contextLength = raw['contextLength'];
else warn('contextLength', 'a number');
}
if ('thinking' in raw) {
if (typeof raw['thinking'] === 'boolean') out.thinking = raw['thinking'];
else warn('thinking', 'a boolean');
}
if ('maxTurns' in raw) {
if (typeof raw['maxTurns'] === 'number') out.maxTurns = raw['maxTurns'];
else warn('maxTurns', 'a number');
}
if ('effort' in raw) {
if (raw['effort'] === 'low' || raw['effort'] === 'medium' || raw['effort'] === 'high') {
out.effort = raw['effort'];
} else {
warn('effort', '"low", "medium", or "high"');
}
}
if ('autoApprove' in raw) {
if (typeof raw['autoApprove'] === 'boolean') out.autoApprove = raw['autoApprove'];
else warn('autoApprove', 'a boolean');
}
if ('maxSessionTokens' in raw) {
if (typeof raw['maxSessionTokens'] === 'number') out.maxSessionTokens = raw['maxSessionTokens'];
else warn('maxSessionTokens', 'a number');
}
if ('budgetWarnThreshold' in raw) {
if (typeof raw['budgetWarnThreshold'] === 'number') out.budgetWarnThreshold = raw['budgetWarnThreshold'];
else warn('budgetWarnThreshold', 'a number');
}
if ('allowedTools' in raw) {
if (Array.isArray(raw['allowedTools']) && raw['allowedTools'].every((v) => typeof v === 'string')) {
out.allowedTools = raw['allowedTools'] as string[];
} else {
warn('allowedTools', 'an array of strings');
}
}
if ('disallowedTools' in raw) {
if (Array.isArray(raw['disallowedTools']) && raw['disallowedTools'].every((v) => typeof v === 'string')) {
out.disallowedTools = raw['disallowedTools'] as string[];
} else {
warn('disallowedTools', 'an array of strings');
}
}
if ('hooks' in raw) {
if (
typeof raw['hooks'] === 'object' &&
raw['hooks'] !== null &&
!Array.isArray(raw['hooks']) &&
Object.values(raw['hooks']).every(
(v) => Array.isArray(v) && (v as unknown[]).every((cmd) => typeof cmd === 'string'),
)
) {
out.hooks = raw['hooks'] as Record<string, string[]>;
} else {
warn('hooks', 'an object mapping event names to arrays of strings');
}
}
if ('verbose' in raw) {
if (typeof raw['verbose'] === 'boolean') out.verbose = raw['verbose'];
else warn('verbose', 'a boolean');
}
if ('theme' in raw) {
if (raw['theme'] === 'default' || raw['theme'] === 'minimal' || raw['theme'] === 'verbose') {
out.theme = raw['theme'];
} else {
warn('theme', '"default", "minimal", or "verbose"');
}
}
return out;
}
/**
* Merges source into target. Primitive fields are replaced; arrays replace
* entirely (no concatenation); hooks objects are shallow-merged at the
* event-name level with arrays replacing.
*/
private deepMerge(
target: KthuluSettings,
source: Partial<KthuluSettings>,
): KthuluSettings {
const result: KthuluSettings = { ...target };
for (const key of Object.keys(source) as (keyof KthuluSettings)[]) {
const value = source[key];
if (value === undefined) continue;
if (key === 'hooks') {
// Merge hook maps: source event arrays replace target event arrays.
const targetHooks = target.hooks ?? {};
const sourceHooks = value as Record<string, string[]>;
result.hooks = { ...targetHooks, ...sourceHooks };
} else {
// Arrays and primitives both replace entirely.
(result as Record<string, unknown>)[key] = value;
}
}
return result;
}
}