feat(coding-agent): migrated settings and models to models.yml

- Settings now stored in ~/.omp/agent/config.yaml instead of agent.db
- Models config now supports models.yaml (preferred) alongside models.json
- Auto-migration from agent.db settings and models.json on first run
- AgentStorage.saveSettings() deprecated (getSettings() kept for migration)
- storage-migration.ts now only handles auth.json migration
This commit is contained in:
can1357
2026-01-23 13:46:06 +01:00
parent 6e5fa5b24d
commit fb68e72d78
6 changed files with 186 additions and 133 deletions
@@ -3,6 +3,7 @@
*/
import { existsSync, readFileSync } from "node:fs";
import { extname } from "node:path";
import {
type Api,
getGitHubCopilotBaseUrl,
@@ -14,6 +15,7 @@ import {
import { logger } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import AjvModule from "ajv";
import { YAML } from "bun";
import type { AuthStorage } from "$c/session/auth-storage";
const Ajv = (AjvModule as any).default || AjvModule;
@@ -262,14 +264,21 @@ export class ModelRegistry {
});
}
private loadCustomModels(modelsJsonPath: string): CustomModelsResult {
if (!existsSync(modelsJsonPath)) {
private loadCustomModels(modelsPath: string): CustomModelsResult {
if (!existsSync(modelsPath)) {
return emptyCustomModelsResult();
}
try {
const content = readFileSync(modelsJsonPath, "utf-8");
const config: ModelsConfig = JSON.parse(content);
const content = readFileSync(modelsPath, "utf-8");
const ext = extname(modelsPath).toLowerCase();
let config: ModelsConfig;
if (ext === ".yaml" || ext === ".yml") {
config = YAML.parse(content) as ModelsConfig;
} else {
config = JSON.parse(content) as ModelsConfig;
}
// Validate schema
const ajv = new Ajv();
@@ -278,7 +287,7 @@ export class ModelRegistry {
const errors =
validate.errors?.map((e: any) => ` - ${e.instancePath || "root"}: ${e.message}`).join("\n") ||
"Unknown schema error";
return emptyCustomModelsResult(`Invalid models.json schema:\n${errors}\n\nFile: ${modelsJsonPath}`);
return emptyCustomModelsResult(`Invalid models config schema:\n${errors}\n\nFile: ${modelsPath}`);
}
// Additional validation
@@ -309,10 +318,10 @@ export class ModelRegistry {
return { models: this.parseModels(config), replacedProviders, overrides, error: undefined };
} catch (error) {
if (error instanceof SyntaxError) {
return emptyCustomModelsResult(`Failed to parse models.json: ${error.message}\n\nFile: ${modelsJsonPath}`);
return emptyCustomModelsResult(`Failed to parse models config: ${error.message}\n\nFile: ${modelsPath}`);
}
return emptyCustomModelsResult(
`Failed to load models.json: ${error instanceof Error ? error.message : error}\n\nFile: ${modelsJsonPath}`,
`Failed to load models config: ${error instanceof Error ? error.message : error}\n\nFile: ${modelsPath}`,
);
}
}
@@ -1,8 +1,10 @@
import { existsSync, mkdirSync, readFileSync } from "node:fs";
import { rename } from "node:fs/promises";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { logger } from "@oh-my-pi/pi-utils";
import { YAML } from "bun";
import { type Settings as SettingsItem, settingsCapability } from "$c/capability/settings";
import { getAgentDbPath, getAgentDir } from "$c/config";
import { getAgentDbPath, getAgentDir, getConfigPath } from "$c/config";
import { loadCapability } from "$c/discovery";
import type { SymbolPreset } from "$c/modes/theme/theme";
import { AgentStorage } from "$c/session/agent-storage";
@@ -424,8 +426,10 @@ function deepMergeSettings(base: Settings, overrides: Settings): Settings {
}
export class SettingsManager {
/** SQLite storage for persisted settings (null for in-memory mode) */
/** SQLite storage for auth/cache (null for in-memory mode) */
private storage: AgentStorage | null;
/** Path to config.yml (null for in-memory mode) */
private configPath: string | null;
private cwd: string | null;
private globalSettings: Settings;
private overrides: Settings;
@@ -434,7 +438,8 @@ export class SettingsManager {
/**
* Private constructor - use static factory methods instead.
* @param storage - SQLite storage instance for persistence, or null for in-memory mode
* @param storage - SQLite storage instance for auth/cache, or null for in-memory mode
* @param configPath - Path to config.yml for persistence, or null for in-memory mode
* @param cwd - Current working directory for project settings discovery
* @param initialSettings - Initial global settings to use
* @param persist - Whether to persist settings changes to storage
@@ -442,12 +447,14 @@ export class SettingsManager {
*/
private constructor(
storage: AgentStorage | null,
configPath: string | null,
cwd: string | null,
initialSettings: Settings,
persist: boolean,
projectSettings: Settings,
) {
this.storage = storage;
this.configPath = configPath;
this.cwd = cwd;
this.persist = persist;
this.globalSettings = initialSettings;
@@ -479,14 +486,17 @@ export class SettingsManager {
}
/**
* Create a SettingsManager that loads from persistent SQLite storage.
* Create a SettingsManager that loads from persistent config.yml.
* @param cwd - Current working directory for project settings discovery
* @param agentDir - Agent directory containing agent.db
* @param agentDir - Agent directory containing config.yml
* @returns Configured SettingsManager with merged global and user settings
*/
static async create(cwd: string = process.cwd(), agentDir: string = getAgentDir()): Promise<SettingsManager> {
const configPath = getConfigPath();
const storage = AgentStorage.open(getAgentDbPath(agentDir));
await SettingsManager.migrateLegacySettingsFile(storage, agentDir);
// Migrate from legacy storage if config.yml doesn't exist
await SettingsManager.migrateToYaml(storage, agentDir, configPath);
// Use capability API to load user-level settings from all providers
const result = await loadCapability(settingsCapability.id, { cwd });
@@ -499,14 +509,14 @@ export class SettingsManager {
}
}
// Load persisted settings from agent.db (legacy settings.json is migrated separately)
const storedSettings = SettingsManager.loadFromStorage(storage);
// Load persisted settings from config.yml
const storedSettings = SettingsManager.loadFromYaml(configPath);
globalSettings = deepMergeSettings(globalSettings, storedSettings);
// Load project settings before construction (constructor is sync)
const projectSettings = await SettingsManager.loadProjectSettingsStatic(cwd);
return new SettingsManager(storage, cwd, globalSettings, true, projectSettings);
return new SettingsManager(storage, configPath, cwd, globalSettings, true, projectSettings);
}
/**
@@ -515,7 +525,7 @@ export class SettingsManager {
* @returns SettingsManager that won't persist changes to disk
*/
static inMemory(settings: Partial<Settings> = {}): SettingsManager {
return new SettingsManager(null, null, settings, false, {});
return new SettingsManager(null, null, null, settings, false, {});
}
/**
@@ -534,41 +544,83 @@ export class SettingsManager {
}
/**
* Load settings from SQLite storage, applying any schema migrations.
* @param storage - AgentStorage instance, or null for in-memory mode
* @returns Parsed and migrated settings, or empty object if storage is null/empty
* Load settings from config.yml, applying any schema migrations.
* @param configPath - Path to config.yml, or null for in-memory mode
* @returns Parsed and migrated settings, or empty object if file doesn't exist
*/
private static loadFromStorage(storage: AgentStorage | null): Settings {
if (!storage) {
private static loadFromYaml(configPath: string | null): Settings {
if (!configPath || !existsSync(configPath)) {
return {};
}
const settings = storage.getSettings();
if (!settings) {
try {
const content = readFileSync(configPath, "utf-8");
const parsed = YAML.parse(content);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return {};
}
return SettingsManager.migrateSettings(parsed as Record<string, unknown>);
} catch (error) {
logger.warn("SettingsManager failed to load config.yml", { path: configPath, error: String(error) });
return {};
}
return SettingsManager.migrateSettings(settings as Record<string, unknown>);
}
private static async migrateLegacySettingsFile(storage: AgentStorage, agentDir: string): Promise<void> {
const settingsPath = join(agentDir, "settings.json");
const settingsFile = Bun.file(settingsPath);
if (!(await settingsFile.exists())) return;
if (storage.getSettings() !== null) return;
/**
* Migrate settings from legacy sources to config.yml.
* Migration order: settings.json -> agent.db -> config.yml
* Only migrates if config.yml doesn't exist.
*/
private static async migrateToYaml(storage: AgentStorage, agentDir: string, configPath: string): Promise<void> {
// Skip if config.yml already exists
if (existsSync(configPath)) return;
let settings: Settings = {};
let migrated = false;
// 1. Try to migrate from settings.json (oldest legacy format)
const settingsJsonPath = join(agentDir, "settings.json");
try {
const parsed = JSON.parse(await settingsFile.text());
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return;
}
const migrated = SettingsManager.migrateSettings(parsed as Record<string, unknown>);
storage.saveSettings(migrated);
try {
await rename(settingsPath, `${settingsPath}.bak`);
} catch (error) {
logger.warn("SettingsManager failed to backup settings.json", { error: String(error) });
const settingsFile = Bun.file(settingsJsonPath);
if (await settingsFile.exists()) {
const parsed = JSON.parse(await settingsFile.text());
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
settings = deepMergeSettings(settings, SettingsManager.migrateSettings(parsed));
migrated = true;
// Backup settings.json
try {
await rename(settingsJsonPath, `${settingsJsonPath}.bak`);
} catch (error) {
logger.warn("SettingsManager failed to backup settings.json", { error: String(error) });
}
}
}
} catch (error) {
logger.warn("SettingsManager failed to migrate settings.json", { error: String(error) });
logger.warn("SettingsManager failed to read settings.json", { error: String(error) });
}
// 2. Migrate from agent.db settings table
try {
const dbSettings = storage.getSettings();
if (dbSettings) {
settings = deepMergeSettings(
settings,
SettingsManager.migrateSettings(dbSettings as Record<string, unknown>),
);
migrated = true;
}
} catch (error) {
logger.warn("SettingsManager failed to read agent.db settings", { error: String(error) });
}
// 3. Write merged settings to config.yml if we found any
if (migrated && Object.keys(settings).length > 0) {
try {
mkdirSync(dirname(configPath), { recursive: true });
await Bun.write(configPath, YAML.stringify(settings));
logger.debug("SettingsManager migrated settings to config.yml", { path: configPath });
} catch (error) {
logger.warn("SettingsManager failed to write config.yml", { path: configPath, error: String(error) });
}
}
}
@@ -620,16 +672,19 @@ export class SettingsManager {
}
/**
* Persist current global settings to SQLite storage and rebuild merged settings.
* Merges with any concurrent changes in storage before saving.
* Persist current global settings to config.yml and rebuild merged settings.
* Merges with any concurrent changes in config file before saving.
*/
private async save(): Promise<void> {
if (this.persist && this.storage) {
if (this.persist && this.configPath) {
try {
const currentSettings = this.storage.getSettings() ?? {};
// Read current settings from file to merge with any concurrent changes
const currentSettings = SettingsManager.loadFromYaml(this.configPath);
const mergedSettings = deepMergeSettings(currentSettings, this.globalSettings);
this.globalSettings = mergedSettings;
this.storage.saveSettings(this.globalSettings);
// Write YAML
mkdirSync(dirname(this.configPath), { recursive: true });
await Bun.write(this.configPath, YAML.stringify(this.globalSettings));
} catch (error) {
logger.warn("SettingsManager save failed", { error: String(error) });
}