- Add intelligent-router.sh hook for automatic agent routing - Add AUTO-TRIGGER-SUMMARY.md documentation - Add FINAL-INTEGRATION-SUMMARY.md documentation - Complete Prometheus integration (6 commands + 4 tools) - Complete Dexto integration (12 commands + 5 tools) - Enhanced Ralph with access to all agents - Fix /clawd command (removed disable-model-invocation) - Update hooks.json to v5 with intelligent routing - 291 total skills now available - All 21 commands with automatic routing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
244 lines
8.3 KiB
TypeScript
244 lines
8.3 KiB
TypeScript
// packages/core/src/preferences/loader.ts
|
|
|
|
import { existsSync } from 'fs';
|
|
import { promises as fs } from 'fs';
|
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
import { getDextoGlobalPath } from '../utils/path.js';
|
|
import { logger } from '@dexto/core';
|
|
import { DextoValidationError, DextoRuntimeError } from '@dexto/core';
|
|
import type { LLMProvider } from '@dexto/core';
|
|
import { GlobalPreferencesSchema, type GlobalPreferences } from './schemas.js';
|
|
import { PREFERENCES_FILE } from './constants.js';
|
|
import { PreferenceError } from './errors.js';
|
|
|
|
/**
|
|
* Load global preferences from ~/.dexto/preferences.yml
|
|
* @returns Global preferences object
|
|
* @throws DextoRuntimeError if file not found or corrupted
|
|
* @throws DextoValidationError if preferences are invalid
|
|
*/
|
|
export async function loadGlobalPreferences(): Promise<GlobalPreferences> {
|
|
const preferencesPath = getDextoGlobalPath(PREFERENCES_FILE);
|
|
|
|
// Check if preferences file exists
|
|
if (!existsSync(preferencesPath)) {
|
|
throw PreferenceError.fileNotFound(preferencesPath);
|
|
}
|
|
|
|
try {
|
|
// Read and parse YAML
|
|
const fileContent = await fs.readFile(preferencesPath, 'utf-8');
|
|
const rawPreferences = parseYaml(fileContent);
|
|
|
|
// Validate with schema
|
|
const validation = GlobalPreferencesSchema.safeParse(rawPreferences);
|
|
if (!validation.success) {
|
|
throw PreferenceError.validationFailed(validation.error);
|
|
}
|
|
|
|
logger.debug(`Loaded global preferences from: ${preferencesPath}`);
|
|
return validation.data;
|
|
} catch (error) {
|
|
if (error instanceof DextoValidationError || error instanceof DextoRuntimeError) {
|
|
throw error; // Re-throw our own errors
|
|
}
|
|
|
|
throw PreferenceError.fileReadError(
|
|
preferencesPath,
|
|
error instanceof Error ? error.message : String(error)
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Header comment for preferences.yml file
|
|
*/
|
|
const PREFERENCES_FILE_HEADER = `# Dexto Global Preferences
|
|
# Documentation: https://dexto.dev/docs/configuration/preferences
|
|
#
|
|
# Sound Notifications:
|
|
# Dexto plays sounds for approval requests and task completion.
|
|
# To customize sounds, place audio files in ~/.dexto/sounds/:
|
|
# - approval.wav (or .mp3, .ogg, .aiff, .m4a) - played when tool approval is needed
|
|
# - complete.wav (or .mp3, .ogg, .aiff, .m4a) - played when agent finishes a task
|
|
# Set sounds.enabled: false to disable all sounds.
|
|
|
|
`;
|
|
|
|
/**
|
|
* Save global preferences to ~/.dexto/preferences.yml
|
|
* @param preferences Validated preferences object
|
|
* @throws DextoRuntimeError if write fails
|
|
*/
|
|
export async function saveGlobalPreferences(preferences: GlobalPreferences): Promise<void> {
|
|
const preferencesPath = getDextoGlobalPath(PREFERENCES_FILE);
|
|
|
|
// Validate preferences against schema before saving
|
|
const validation = GlobalPreferencesSchema.safeParse(preferences);
|
|
if (!validation.success) {
|
|
throw PreferenceError.validationFailed(validation.error);
|
|
}
|
|
|
|
try {
|
|
logger.debug(`Saving global preferences to: ${preferencesPath}`);
|
|
// Ensure ~/.dexto directory exists
|
|
const dextoDir = getDextoGlobalPath('');
|
|
await fs.mkdir(dextoDir, { recursive: true });
|
|
|
|
// Convert to YAML with nice formatting
|
|
const yamlContent = stringifyYaml(preferences, {
|
|
indent: 2,
|
|
lineWidth: 100,
|
|
minContentWidth: 20,
|
|
});
|
|
|
|
// Write to file with header comment
|
|
await fs.writeFile(preferencesPath, PREFERENCES_FILE_HEADER + yamlContent, 'utf-8');
|
|
|
|
logger.debug(
|
|
`✓ Saved global preferences ${JSON.stringify(preferences)} to: ${preferencesPath}`
|
|
);
|
|
} catch (error) {
|
|
throw PreferenceError.fileWriteError(
|
|
preferencesPath,
|
|
error instanceof Error ? error.message : String(error)
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if global preferences exist (for first-time detection)
|
|
* @returns true if preferences.yml exists
|
|
*/
|
|
export function globalPreferencesExist(): boolean {
|
|
const preferencesPath = getDextoGlobalPath(PREFERENCES_FILE);
|
|
return existsSync(preferencesPath);
|
|
}
|
|
|
|
/**
|
|
* Get global preferences file path
|
|
* @returns Absolute path to preferences.yml
|
|
*/
|
|
export function getGlobalPreferencesPath(): string {
|
|
return getDextoGlobalPath(PREFERENCES_FILE);
|
|
}
|
|
|
|
/**
|
|
* Options for creating initial preferences
|
|
*/
|
|
export interface CreatePreferencesOptions {
|
|
provider: LLMProvider;
|
|
model: string;
|
|
/** API key env var (optional for providers like Ollama that don't need auth) */
|
|
apiKeyVar?: string;
|
|
defaultAgent?: string;
|
|
defaultMode?: 'cli' | 'web' | 'server' | 'discord' | 'telegram' | 'mcp';
|
|
baseURL?: string;
|
|
/** Reasoning effort for OpenAI reasoning models (o1, o3, codex, gpt-5.x) */
|
|
reasoningEffort?: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh';
|
|
setupCompleted?: boolean;
|
|
/** Whether API key setup was skipped and needs to be configured later */
|
|
apiKeyPending?: boolean;
|
|
/** Whether baseURL setup was skipped and needs to be configured later */
|
|
baseURLPending?: boolean;
|
|
/** Sound notification preferences */
|
|
sounds?: {
|
|
enabled?: boolean;
|
|
onApprovalRequired?: boolean;
|
|
onTaskComplete?: boolean;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Create initial preferences from setup data
|
|
* @param options Configuration options for preferences
|
|
*/
|
|
export function createInitialPreferences(options: CreatePreferencesOptions): GlobalPreferences {
|
|
const llmConfig: GlobalPreferences['llm'] = {
|
|
provider: options.provider,
|
|
model: options.model,
|
|
};
|
|
|
|
// Only add apiKey if provided (optional for local providers like Ollama)
|
|
if (options.apiKeyVar) {
|
|
llmConfig.apiKey = '$' + options.apiKeyVar;
|
|
}
|
|
|
|
// Only add baseURL if provided
|
|
if (options.baseURL) {
|
|
llmConfig.baseURL = options.baseURL;
|
|
}
|
|
|
|
// Only add reasoningEffort if provided
|
|
if (options.reasoningEffort) {
|
|
llmConfig.reasoningEffort = options.reasoningEffort;
|
|
}
|
|
|
|
return {
|
|
llm: llmConfig,
|
|
defaults: {
|
|
defaultAgent: options.defaultAgent || 'coding-agent',
|
|
defaultMode: options.defaultMode || 'web',
|
|
},
|
|
setup: {
|
|
completed: options.setupCompleted ?? true,
|
|
apiKeyPending: options.apiKeyPending ?? false,
|
|
baseURLPending: options.baseURLPending ?? false,
|
|
},
|
|
sounds: {
|
|
enabled: options.sounds?.enabled ?? true,
|
|
onApprovalRequired: options.sounds?.onApprovalRequired ?? true,
|
|
onTaskComplete: options.sounds?.onTaskComplete ?? true,
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Updates type that allows partial nested objects
|
|
*/
|
|
export type GlobalPreferencesUpdates = {
|
|
llm?: GlobalPreferences['llm'];
|
|
defaults?: Partial<GlobalPreferences['defaults']>;
|
|
setup?: Partial<GlobalPreferences['setup']>;
|
|
sounds?: Partial<GlobalPreferences['sounds']>;
|
|
};
|
|
|
|
/**
|
|
* Update specific preference sections
|
|
* @param updates Partial preference updates
|
|
* @returns Updated preferences object
|
|
* @throws DextoRuntimeError if load/save fails
|
|
* @throws DextoValidationError if merged preferences are invalid
|
|
*/
|
|
export async function updateGlobalPreferences(
|
|
updates: GlobalPreferencesUpdates
|
|
): Promise<GlobalPreferences> {
|
|
// Load existing preferences
|
|
const existing = await loadGlobalPreferences();
|
|
|
|
// Hybrid merge strategy: different sections have different coherence requirements
|
|
const merged = {
|
|
...existing,
|
|
...updates,
|
|
// LLM section requires complete replacement (high coherence - provider/model/apiKey must match)
|
|
llm: updates.llm || existing.llm,
|
|
// Defaults, setup, and sounds sections allow partial updates (low coherence - independent fields)
|
|
defaults: updates.defaults
|
|
? { ...existing.defaults, ...updates.defaults }
|
|
: existing.defaults,
|
|
setup: updates.setup ? { ...existing.setup, ...updates.setup } : existing.setup,
|
|
sounds: updates.sounds ? { ...existing.sounds, ...updates.sounds } : existing.sounds,
|
|
};
|
|
|
|
// Validate merged result
|
|
const validation = GlobalPreferencesSchema.safeParse(merged);
|
|
if (!validation.success) {
|
|
throw PreferenceError.validationFailed(validation.error);
|
|
}
|
|
|
|
// Save updated preferences
|
|
await saveGlobalPreferences(validation.data);
|
|
|
|
return validation.data;
|
|
}
|