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
*
* 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();
console.log("Current settings:", JSON.stringify(settings, null, 2));
// Override specific settings
const settingsManager = SettingsManager.create();
settingsManager.applyOverrides({
compaction: { enabled: false },
retry: { enabled: true, maxRetries: 5, baseDelayMs: 1000 },
});
const { session } = await createAgentSession({
settings: {
// Disable auto-compaction
compaction: { enabled: false },
// Custom retry behavior
retry: {
enabled: true,
maxRetries: 5,
baseDelayMs: 1000,
},
// Terminal options
terminal: { showImages: true },
hideThinkingBlock: true,
},
settingsManager,
sessionManager: SessionManager.inMemory(),
});
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,
findModel,
SessionManager,
SettingsManager,
readTool,
bashTool,
type HookFactory,
@ -53,6 +54,12 @@ const statusTool: CustomAgentTool = {
const { model } = findModel("anthropic", "claude-sonnet-4-20250514");
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({
cwd: process.cwd(),
agentDir: "/tmp/my-agent",
@ -71,7 +78,7 @@ Available: read, bash, status. Be concise.`,
contextFiles: [],
slashCommands: [],
sessionManager: SessionManager.inMemory(),
settings: { compaction: { enabled: false } },
settingsManager,
});
session.subscribe((event) => {

View file

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

View file

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

View file

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

View file

@ -34,7 +34,7 @@ export function getShellConfig(): { shell: string; args: string[] } {
return cachedShellConfig;
}
const settings = new SettingsManager();
const settings = SettingsManager.create();
const customShellPath = settings.getShellPath();
// 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);
const settingsManager = new SettingsManager(tempDir);
const settingsManager = SettingsManager.create(tempDir, tempDir);
session = new AgentSession({
agent,

View file

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