Add project-specific settings and SettingsManager factories

- SettingsManager now loads .pi/settings.json from cwd (project settings)
- Project settings merge with global settings (deep merge for objects)
- Setters only modify global settings, project settings are read-only
- Add static factories: SettingsManager.create(cwd?, agentDir?), SettingsManager.inMemory(settings?)
- Add applyOverrides() for programmatic overrides
- Replace 'settings' option with 'settingsManager' in CreateAgentSessionOptions
- Update examples to use new pattern

Incorporates PR #276 approach
This commit is contained in:
Mario Zechner 2025-12-22 12:23:02 +01:00
parent 05e1f31feb
commit 62c64a286b
8 changed files with 162 additions and 71 deletions

View file

@ -1,33 +1,38 @@
/** /**
* Settings Configuration * Settings Configuration
* *
* Override settings from agentDir/settings.json. * Override settings using SettingsManager.
*/ */
import { createAgentSession, loadSettings, SessionManager } from "../../src/index.js"; import { createAgentSession, loadSettings, SessionManager, SettingsManager } from "../../src/index.js";
// Load current settings // Load current settings (merged global + project)
const settings = loadSettings(); const settings = loadSettings();
console.log("Current settings:", JSON.stringify(settings, null, 2)); console.log("Current settings:", JSON.stringify(settings, null, 2));
// Override specific settings // Override specific settings
const settingsManager = SettingsManager.create();
settingsManager.applyOverrides({
compaction: { enabled: false },
retry: { enabled: true, maxRetries: 5, baseDelayMs: 1000 },
});
const { session } = await createAgentSession({ const { session } = await createAgentSession({
settings: { settingsManager,
// Disable auto-compaction
compaction: { enabled: false },
// Custom retry behavior
retry: {
enabled: true,
maxRetries: 5,
baseDelayMs: 1000,
},
// Terminal options
terminal: { showImages: true },
hideThinkingBlock: true,
},
sessionManager: SessionManager.inMemory(), sessionManager: SessionManager.inMemory(),
}); });
console.log("Session created with custom settings"); console.log("Session created with custom settings");
// For testing without file I/O:
const inMemorySettings = SettingsManager.inMemory({
compaction: { enabled: false },
retry: { enabled: false },
});
const { session: testSession } = await createAgentSession({
settingsManager: inMemorySettings,
sessionManager: SessionManager.inMemory(),
});
console.log("Test session created with in-memory settings");

View file

@ -12,6 +12,7 @@ import {
defaultGetApiKey, defaultGetApiKey,
findModel, findModel,
SessionManager, SessionManager,
SettingsManager,
readTool, readTool,
bashTool, bashTool,
type HookFactory, type HookFactory,
@ -53,6 +54,12 @@ const statusTool: CustomAgentTool = {
const { model } = findModel("anthropic", "claude-sonnet-4-20250514"); const { model } = findModel("anthropic", "claude-sonnet-4-20250514");
if (!model) throw new Error("Model not found"); if (!model) throw new Error("Model not found");
// In-memory settings with overrides
const settingsManager = SettingsManager.inMemory({
compaction: { enabled: false },
retry: { enabled: true, maxRetries: 2 },
});
const { session } = await createAgentSession({ const { session } = await createAgentSession({
cwd: process.cwd(), cwd: process.cwd(),
agentDir: "/tmp/my-agent", agentDir: "/tmp/my-agent",
@ -71,7 +78,7 @@ Available: read, bash, status. Be concise.`,
contextFiles: [], contextFiles: [],
slashCommands: [], slashCommands: [],
sessionManager: SessionManager.inMemory(), sessionManager: SessionManager.inMemory(),
settings: { compaction: { enabled: false } }, settingsManager,
}); });
session.subscribe((event) => { session.subscribe((event) => {

View file

@ -111,8 +111,8 @@ export interface CreateAgentSessionOptions {
/** Session manager. Default: SessionManager.create(cwd) */ /** Session manager. Default: SessionManager.create(cwd) */
sessionManager?: SessionManager; sessionManager?: SessionManager;
/** Settings overrides (merged with agentDir/settings.json) */ /** Settings manager. Default: SettingsManager.create(cwd, agentDir) */
settings?: Partial<Settings>; settingsManager?: SettingsManager;
} }
/** Result from createAgentSession */ /** Result from createAgentSession */
@ -338,10 +338,10 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin
// Settings // Settings
/** /**
* Load settings from agentDir/settings.json. * Load settings from agentDir/settings.json merged with cwd/.pi/settings.json.
*/ */
export function loadSettings(agentDir?: string): Settings { export function loadSettings(cwd?: string, agentDir?: string): Settings {
const manager = new SettingsManager(agentDir ?? getDefaultAgentDir()); const manager = SettingsManager.create(cwd ?? process.cwd(), agentDir ?? getDefaultAgentDir());
return { return {
defaultProvider: manager.getDefaultProvider(), defaultProvider: manager.getDefaultProvider(),
defaultModel: manager.getDefaultModel(), defaultModel: manager.getDefaultModel(),
@ -449,8 +449,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
// Configure OAuth storage for this agentDir // Configure OAuth storage for this agentDir
configureOAuthStorage(agentDir); configureOAuthStorage(agentDir);
const settingsManager = new SettingsManager(agentDir); const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir);
const sessionManager = options.sessionManager ?? SessionManager.create(cwd); const sessionManager = options.sessionManager ?? SessionManager.create(cwd, agentDir);
// Check if session has existing data to restore // Check if session has existing data to restore
const existingSession = sessionManager.loadSession(); const existingSession = sessionManager.loadSession();

View file

@ -1,6 +1,6 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { dirname, join } from "path"; import { dirname, join } from "path";
import { getAgentDir } from "../config.js"; import { CONFIG_DIR_NAME, getAgentDir } from "../config.js";
export interface CompactionSettings { export interface CompactionSettings {
enabled?: boolean; // default: true enabled?: boolean; // default: true
@ -49,39 +49,118 @@ export interface Settings {
terminal?: TerminalSettings; terminal?: TerminalSettings;
} }
export class SettingsManager { /** Deep merge settings: project/overrides take precedence, nested objects merge recursively */
private settingsPath: string; function deepMergeSettings(base: Settings, overrides: Settings): Settings {
private settings: Settings; const result: Settings = { ...base };
constructor(baseDir?: string) { for (const key of Object.keys(overrides) as (keyof Settings)[]) {
const dir = baseDir || getAgentDir(); const overrideValue = overrides[key];
this.settingsPath = join(dir, "settings.json"); const baseValue = base[key];
this.settings = this.load();
if (overrideValue === undefined) {
continue;
}
// For nested objects, merge recursively
if (
typeof overrideValue === "object" &&
overrideValue !== null &&
!Array.isArray(overrideValue) &&
typeof baseValue === "object" &&
baseValue !== null &&
!Array.isArray(baseValue)
) {
(result as Record<string, unknown>)[key] = { ...baseValue, ...overrideValue };
} else {
// For primitives and arrays, override value wins
(result as Record<string, unknown>)[key] = overrideValue;
}
} }
private load(): Settings { return result;
if (!existsSync(this.settingsPath)) { }
export class SettingsManager {
private settingsPath: string | null;
private projectSettingsPath: string | null;
private globalSettings: Settings;
private settings: Settings;
private persist: boolean;
private constructor(
settingsPath: string | null,
projectSettingsPath: string | null,
initialSettings: Settings,
persist: boolean,
) {
this.settingsPath = settingsPath;
this.projectSettingsPath = projectSettingsPath;
this.persist = persist;
this.globalSettings = initialSettings;
const projectSettings = this.loadProjectSettings();
this.settings = deepMergeSettings(this.globalSettings, projectSettings);
}
/** Create a SettingsManager that loads from files */
static create(cwd: string = process.cwd(), agentDir: string = getAgentDir()): SettingsManager {
const settingsPath = join(agentDir, "settings.json");
const projectSettingsPath = join(cwd, CONFIG_DIR_NAME, "settings.json");
const globalSettings = SettingsManager.loadFromFile(settingsPath);
return new SettingsManager(settingsPath, projectSettingsPath, globalSettings, true);
}
/** Create an in-memory SettingsManager (no file I/O) */
static inMemory(settings: Partial<Settings> = {}): SettingsManager {
return new SettingsManager(null, null, settings, false);
}
private static loadFromFile(path: string): Settings {
if (!existsSync(path)) {
return {};
}
try {
const content = readFileSync(path, "utf-8");
return JSON.parse(content);
} catch (error) {
console.error(`Warning: Could not read settings file ${path}: ${error}`);
return {};
}
}
private loadProjectSettings(): Settings {
if (!this.projectSettingsPath || !existsSync(this.projectSettingsPath)) {
return {}; return {};
} }
try { try {
const content = readFileSync(this.settingsPath, "utf-8"); const content = readFileSync(this.projectSettingsPath, "utf-8");
return JSON.parse(content); return JSON.parse(content);
} catch (error) { } catch (error) {
console.error(`Warning: Could not read settings file: ${error}`); console.error(`Warning: Could not read project settings file: ${error}`);
return {}; return {};
} }
} }
/** Apply additional overrides on top of current settings */
applyOverrides(overrides: Partial<Settings>): void {
this.settings = deepMergeSettings(this.settings, overrides);
}
private save(): void { private save(): void {
if (!this.persist || !this.settingsPath) return;
try { try {
// Ensure directory exists
const dir = dirname(this.settingsPath); const dir = dirname(this.settingsPath);
if (!existsSync(dir)) { if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true }); mkdirSync(dir, { recursive: true });
} }
writeFileSync(this.settingsPath, JSON.stringify(this.settings, null, 2), "utf-8"); // Save only global settings (project settings are read-only)
writeFileSync(this.settingsPath, JSON.stringify(this.globalSettings, null, 2), "utf-8");
// Re-merge project settings into active settings
const projectSettings = this.loadProjectSettings();
this.settings = deepMergeSettings(this.globalSettings, projectSettings);
} catch (error) { } catch (error) {
console.error(`Warning: Could not save settings file: ${error}`); console.error(`Warning: Could not save settings file: ${error}`);
} }
@ -92,7 +171,7 @@ export class SettingsManager {
} }
setLastChangelogVersion(version: string): void { setLastChangelogVersion(version: string): void {
this.settings.lastChangelogVersion = version; this.globalSettings.lastChangelogVersion = version;
this.save(); this.save();
} }
@ -105,18 +184,18 @@ export class SettingsManager {
} }
setDefaultProvider(provider: string): void { setDefaultProvider(provider: string): void {
this.settings.defaultProvider = provider; this.globalSettings.defaultProvider = provider;
this.save(); this.save();
} }
setDefaultModel(modelId: string): void { setDefaultModel(modelId: string): void {
this.settings.defaultModel = modelId; this.globalSettings.defaultModel = modelId;
this.save(); this.save();
} }
setDefaultModelAndProvider(provider: string, modelId: string): void { setDefaultModelAndProvider(provider: string, modelId: string): void {
this.settings.defaultProvider = provider; this.globalSettings.defaultProvider = provider;
this.settings.defaultModel = modelId; this.globalSettings.defaultModel = modelId;
this.save(); this.save();
} }
@ -125,7 +204,7 @@ export class SettingsManager {
} }
setQueueMode(mode: "all" | "one-at-a-time"): void { setQueueMode(mode: "all" | "one-at-a-time"): void {
this.settings.queueMode = mode; this.globalSettings.queueMode = mode;
this.save(); this.save();
} }
@ -134,7 +213,7 @@ export class SettingsManager {
} }
setTheme(theme: string): void { setTheme(theme: string): void {
this.settings.theme = theme; this.globalSettings.theme = theme;
this.save(); this.save();
} }
@ -143,7 +222,7 @@ export class SettingsManager {
} }
setDefaultThinkingLevel(level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"): void { setDefaultThinkingLevel(level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"): void {
this.settings.defaultThinkingLevel = level; this.globalSettings.defaultThinkingLevel = level;
this.save(); this.save();
} }
@ -152,10 +231,10 @@ export class SettingsManager {
} }
setCompactionEnabled(enabled: boolean): void { setCompactionEnabled(enabled: boolean): void {
if (!this.settings.compaction) { if (!this.globalSettings.compaction) {
this.settings.compaction = {}; this.globalSettings.compaction = {};
} }
this.settings.compaction.enabled = enabled; this.globalSettings.compaction.enabled = enabled;
this.save(); this.save();
} }
@ -180,10 +259,10 @@ export class SettingsManager {
} }
setRetryEnabled(enabled: boolean): void { setRetryEnabled(enabled: boolean): void {
if (!this.settings.retry) { if (!this.globalSettings.retry) {
this.settings.retry = {}; this.globalSettings.retry = {};
} }
this.settings.retry.enabled = enabled; this.globalSettings.retry.enabled = enabled;
this.save(); this.save();
} }
@ -200,7 +279,7 @@ export class SettingsManager {
} }
setHideThinkingBlock(hide: boolean): void { setHideThinkingBlock(hide: boolean): void {
this.settings.hideThinkingBlock = hide; this.globalSettings.hideThinkingBlock = hide;
this.save(); this.save();
} }
@ -209,7 +288,7 @@ export class SettingsManager {
} }
setShellPath(path: string | undefined): void { setShellPath(path: string | undefined): void {
this.settings.shellPath = path; this.globalSettings.shellPath = path;
this.save(); this.save();
} }
@ -218,7 +297,7 @@ export class SettingsManager {
} }
setCollapseChangelog(collapse: boolean): void { setCollapseChangelog(collapse: boolean): void {
this.settings.collapseChangelog = collapse; this.globalSettings.collapseChangelog = collapse;
this.save(); this.save();
} }
@ -227,7 +306,7 @@ export class SettingsManager {
} }
setHookPaths(paths: string[]): void { setHookPaths(paths: string[]): void {
this.settings.hooks = paths; this.globalSettings.hooks = paths;
this.save(); this.save();
} }
@ -236,7 +315,7 @@ export class SettingsManager {
} }
setHookTimeout(timeout: number): void { setHookTimeout(timeout: number): void {
this.settings.hookTimeout = timeout; this.globalSettings.hookTimeout = timeout;
this.save(); this.save();
} }
@ -245,7 +324,7 @@ export class SettingsManager {
} }
setCustomToolPaths(paths: string[]): void { setCustomToolPaths(paths: string[]): void {
this.settings.customTools = paths; this.globalSettings.customTools = paths;
this.save(); this.save();
} }
@ -254,10 +333,10 @@ export class SettingsManager {
} }
setSkillsEnabled(enabled: boolean): void { setSkillsEnabled(enabled: boolean): void {
if (!this.settings.skills) { if (!this.globalSettings.skills) {
this.settings.skills = {}; this.globalSettings.skills = {};
} }
this.settings.skills.enabled = enabled; this.globalSettings.skills.enabled = enabled;
this.save(); this.save();
} }
@ -280,10 +359,10 @@ export class SettingsManager {
} }
setShowImages(show: boolean): void { setShowImages(show: boolean): void {
if (!this.settings.terminal) { if (!this.globalSettings.terminal) {
this.settings.terminal = {}; this.globalSettings.terminal = {};
} }
this.settings.terminal.showImages = show; this.globalSettings.terminal.showImages = show;
this.save(); this.save();
} }
} }

View file

@ -288,7 +288,7 @@ export async function main(args: string[]) {
const isInteractive = !parsed.print && parsed.mode === undefined; const isInteractive = !parsed.print && parsed.mode === undefined;
const mode = parsed.mode || "text"; const mode = parsed.mode || "text";
const settingsManager = new SettingsManager(); const settingsManager = SettingsManager.create(cwd);
initTheme(settingsManager.getTheme(), isInteractive); initTheme(settingsManager.getTheme(), isInteractive);
let scopedModels: ScopedModel[] = []; let scopedModels: ScopedModel[] = [];

View file

@ -34,7 +34,7 @@ export function getShellConfig(): { shell: string; args: string[] } {
return cachedShellConfig; return cachedShellConfig;
} }
const settings = new SettingsManager(); const settings = SettingsManager.create();
const customShellPath = settings.getShellPath(); const customShellPath = settings.getShellPath();
// 1. Check user-specified shell path // 1. Check user-specified shell path

View file

@ -57,7 +57,7 @@ describe.skipIf(!API_KEY)("AgentSession branching", () => {
}); });
sessionManager = noSession ? SessionManager.inMemory() : SessionManager.create(tempDir); sessionManager = noSession ? SessionManager.inMemory() : SessionManager.create(tempDir);
const settingsManager = new SettingsManager(tempDir); const settingsManager = SettingsManager.create(tempDir, tempDir);
session = new AgentSession({ session = new AgentSession({
agent, agent,

View file

@ -61,7 +61,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
}); });
sessionManager = SessionManager.create(tempDir); sessionManager = SessionManager.create(tempDir);
const settingsManager = new SettingsManager(tempDir); const settingsManager = SettingsManager.create(tempDir, tempDir);
session = new AgentSession({ session = new AgentSession({
agent, agent,
@ -177,7 +177,7 @@ describe.skipIf(!API_KEY)("AgentSession compaction e2e", () => {
// Create in-memory session manager // Create in-memory session manager
const noSessionManager = SessionManager.inMemory(); const noSessionManager = SessionManager.inMemory();
const settingsManager = new SettingsManager(tempDir); const settingsManager = SettingsManager.create(tempDir, tempDir);
const noSessionSession = new AgentSession({ const noSessionSession = new AgentSession({
agent, agent,