/** * SDK for programmatic usage of AgentSession. * * Provides a factory function and discovery helpers that allow full control * over agent configuration, or sensible defaults that match CLI behavior. * * @example * ```typescript * // Minimal - everything auto-discovered * const session = await createAgentSession(); * * // Full control * const session = await createAgentSession({ * model: myModel, * getApiKey: async () => process.env.MY_KEY, * tools: [readTool, bashTool], * skills: [], * sessionFile: false, * }); * ``` */ import { Agent, type AgentMessage, type AgentTool, type ThinkingLevel } from "@mariozechner/pi-agent-core"; import type { Message, Model } from "@mariozechner/pi-ai"; import { join } from "path"; import { getAgentDir } from "../config.js"; import { AgentSession } from "./agent-session.js"; import { AuthStorage } from "./auth-storage.js"; import { createEventBus, type EventBus } from "./event-bus.js"; import { createExtensionRuntime, discoverAndLoadExtensions, type ExtensionFactory, ExtensionRunner, type LoadExtensionsResult, loadExtensionFromFactory, type ToolDefinition, wrapRegisteredTools, wrapToolsWithExtensions, } from "./extensions/index.js"; import { convertToLlm } from "./messages.js"; import { ModelRegistry } from "./model-registry.js"; import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate } from "./prompt-templates.js"; import { SessionManager } from "./session-manager.js"; import { type Settings, SettingsManager, type SkillsSettings } from "./settings-manager.js"; import { loadSkills as loadSkillsInternal, type Skill, type SkillWarning } from "./skills.js"; import { buildSystemPrompt as buildSystemPromptInternal, loadProjectContextFiles as loadContextFilesInternal, } from "./system-prompt.js"; import { time } from "./timings.js"; import { allTools, bashTool, codingTools, createAllTools, createBashTool, createCodingTools, createEditTool, createFindTool, createGrepTool, createLsTool, createReadOnlyTools, createReadTool, createWriteTool, editTool, findTool, grepTool, lsTool, readOnlyTools, readTool, type Tool, type ToolName, writeTool, } from "./tools/index.js"; // Types export interface CreateAgentSessionOptions { /** Working directory for project-local discovery. Default: process.cwd() */ cwd?: string; /** Global config directory. Default: ~/.pi/agent */ agentDir?: string; /** Auth storage for credentials. Default: discoverAuthStorage(agentDir) */ authStorage?: AuthStorage; /** Model registry. Default: discoverModels(authStorage, agentDir) */ modelRegistry?: ModelRegistry; /** Model to use. Default: from settings, else first available */ model?: Model; /** Thinking level. Default: from settings, else 'off' (clamped to model capabilities) */ thinkingLevel?: ThinkingLevel; /** Models available for cycling (Ctrl+P in interactive mode) */ scopedModels?: Array<{ model: Model; thinkingLevel: ThinkingLevel }>; /** System prompt. String replaces default, function receives default and returns final. */ systemPrompt?: string | ((defaultPrompt: string) => string); /** Built-in tools to use. Default: codingTools [read, bash, edit, write] */ tools?: Tool[]; /** Custom tools to register (in addition to built-in tools). */ customTools?: ToolDefinition[]; /** Inline extensions. When provided (even if empty), skips file discovery. */ extensions?: ExtensionFactory[]; /** Additional extension paths to load (merged with discovery). */ additionalExtensionPaths?: string[]; /** * Pre-loaded extensions result (skips file discovery). * @internal Used by CLI when extensions are loaded early to parse custom flags. */ preloadedExtensions?: LoadExtensionsResult; /** Shared event bus for tool/extension communication. Default: creates new bus. */ eventBus?: EventBus; /** Skills. Default: discovered from multiple locations */ skills?: Skill[]; /** Context files (AGENTS.md content). Default: discovered walking up from cwd */ contextFiles?: Array<{ path: string; content: string }>; /** Prompt templates. Default: discovered from cwd/.pi/prompts/ + agentDir/prompts/ */ promptTemplates?: PromptTemplate[]; /** Session manager. Default: SessionManager.create(cwd) */ sessionManager?: SessionManager; /** Settings manager. Default: SettingsManager.create(cwd, agentDir) */ settingsManager?: SettingsManager; } /** Result from createAgentSession */ export interface CreateAgentSessionResult { /** The created session */ session: AgentSession; /** Extensions result (for UI context setup in interactive mode) */ extensionsResult: LoadExtensionsResult; /** Warning if session was restored with a different model than saved */ modelFallbackMessage?: string; } // Re-exports export type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, ExtensionFactory, ToolDefinition, } from "./extensions/index.js"; export type { PromptTemplate } from "./prompt-templates.js"; export type { Settings, SkillsSettings } from "./settings-manager.js"; export type { Skill } from "./skills.js"; export type { Tool } from "./tools/index.js"; export { // Pre-built tools (use process.cwd()) readTool, bashTool, editTool, writeTool, grepTool, findTool, lsTool, codingTools, readOnlyTools, allTools as allBuiltInTools, // Tool factories (for custom cwd) createCodingTools, createReadOnlyTools, createReadTool, createBashTool, createEditTool, createWriteTool, createGrepTool, createFindTool, createLsTool, }; // Helper Functions function getDefaultAgentDir(): string { return getAgentDir(); } // Discovery Functions /** * Create an AuthStorage instance for the given agent directory. */ export function discoverAuthStorage(agentDir: string = getDefaultAgentDir()): AuthStorage { return new AuthStorage(join(agentDir, "auth.json")); } /** * Create a ModelRegistry for the given agent directory. */ export function discoverModels(authStorage: AuthStorage, agentDir: string = getDefaultAgentDir()): ModelRegistry { return new ModelRegistry(authStorage, join(agentDir, "models.json")); } /** * Discover extensions from cwd and agentDir. * @param eventBus - Shared event bus for extension communication. Pass to createAgentSession too. * @param cwd - Current working directory * @param agentDir - Agent configuration directory */ export async function discoverExtensions( eventBus: EventBus, cwd?: string, agentDir?: string, ): Promise { const resolvedCwd = cwd ?? process.cwd(); const resolvedAgentDir = agentDir ?? getDefaultAgentDir(); const result = await discoverAndLoadExtensions([], resolvedCwd, resolvedAgentDir, eventBus); // Log errors but don't fail for (const { path, error } of result.errors) { console.error(`Failed to load extension "${path}": ${error}`); } return result; } /** * Discover skills from cwd and agentDir. */ export function discoverSkills( cwd?: string, agentDir?: string, settings?: SkillsSettings, ): { skills: Skill[]; warnings: SkillWarning[] } { return loadSkillsInternal({ ...settings, cwd: cwd ?? process.cwd(), agentDir: agentDir ?? getDefaultAgentDir(), }); } /** * Discover context files (AGENTS.md) walking up from cwd. */ export function discoverContextFiles(cwd?: string, agentDir?: string): Array<{ path: string; content: string }> { return loadContextFilesInternal({ cwd: cwd ?? process.cwd(), agentDir: agentDir ?? getDefaultAgentDir(), }); } /** * Discover prompt templates from cwd and agentDir. */ export function discoverPromptTemplates(cwd?: string, agentDir?: string): PromptTemplate[] { return loadPromptTemplatesInternal({ cwd: cwd ?? process.cwd(), agentDir: agentDir ?? getDefaultAgentDir(), }); } // API Key Helpers // System Prompt export interface BuildSystemPromptOptions { tools?: Tool[]; skills?: Skill[]; contextFiles?: Array<{ path: string; content: string }>; cwd?: string; appendPrompt?: string; } /** * Build the default system prompt. */ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): string { return buildSystemPromptInternal({ cwd: options.cwd, skills: options.skills, contextFiles: options.contextFiles, appendSystemPrompt: options.appendPrompt, }); } // Settings /** * Load settings from agentDir/settings.json merged with cwd/.pi/settings.json. */ export function loadSettings(cwd?: string, agentDir?: string): Settings { const manager = SettingsManager.create(cwd ?? process.cwd(), agentDir ?? getDefaultAgentDir()); return { defaultProvider: manager.getDefaultProvider(), defaultModel: manager.getDefaultModel(), defaultThinkingLevel: manager.getDefaultThinkingLevel(), steeringMode: manager.getSteeringMode(), followUpMode: manager.getFollowUpMode(), theme: manager.getTheme(), compaction: manager.getCompactionSettings(), retry: manager.getRetrySettings(), hideThinkingBlock: manager.getHideThinkingBlock(), shellPath: manager.getShellPath(), collapseChangelog: manager.getCollapseChangelog(), extensions: manager.getExtensionPaths(), skills: manager.getSkillsSettings(), terminal: { showImages: manager.getShowImages() }, images: { autoResize: manager.getImageAutoResize(), blockImages: manager.getBlockImages() }, }; } // Factory /** * Create an AgentSession with the specified options. * * @example * ```typescript * // Minimal - uses defaults * const { session } = await createAgentSession(); * * // With explicit model * import { getModel } from '@mariozechner/pi-ai'; * const { session } = await createAgentSession({ * model: getModel('anthropic', 'claude-opus-4-5'), * thinkingLevel: 'high', * }); * * // Continue previous session * const { session, modelFallbackMessage } = await createAgentSession({ * continueSession: true, * }); * * // Full control * const { session } = await createAgentSession({ * model: myModel, * getApiKey: async () => process.env.MY_KEY, * systemPrompt: 'You are helpful.', * tools: [readTool, bashTool], * skills: [], * sessionManager: SessionManager.inMemory(), * }); * ``` */ export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise { const cwd = options.cwd ?? process.cwd(); const agentDir = options.agentDir ?? getDefaultAgentDir(); const eventBus = options.eventBus ?? createEventBus(); // Use provided or create AuthStorage and ModelRegistry const authStorage = options.authStorage ?? discoverAuthStorage(agentDir); const modelRegistry = options.modelRegistry ?? discoverModels(authStorage, agentDir); time("discoverModels"); const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir); time("settingsManager"); const sessionManager = options.sessionManager ?? SessionManager.create(cwd); time("sessionManager"); // Check if session has existing data to restore const existingSession = sessionManager.buildSessionContext(); time("loadSession"); const hasExistingSession = existingSession.messages.length > 0; let model = options.model; let modelFallbackMessage: string | undefined; // If session has data, try to restore model from it if (!model && hasExistingSession && existingSession.model) { const restoredModel = modelRegistry.find(existingSession.model.provider, existingSession.model.modelId); if (restoredModel && (await modelRegistry.getApiKey(restoredModel))) { model = restoredModel; } if (!model) { modelFallbackMessage = `Could not restore model ${existingSession.model.provider}/${existingSession.model.modelId}`; } } // If still no model, try settings default if (!model) { const defaultProvider = settingsManager.getDefaultProvider(); const defaultModelId = settingsManager.getDefaultModel(); if (defaultProvider && defaultModelId) { const settingsModel = modelRegistry.find(defaultProvider, defaultModelId); if (settingsModel && (await modelRegistry.getApiKey(settingsModel))) { model = settingsModel; } } } // Fall back to first available model with a valid API key if (!model) { for (const m of modelRegistry.getAll()) { if (await modelRegistry.getApiKey(m)) { model = m; break; } } time("findAvailableModel"); if (model) { if (modelFallbackMessage) { modelFallbackMessage += `. Using ${model.provider}/${model.id}`; } } else { // No models available - set message so user knows to /login or configure keys modelFallbackMessage = "No models available. Use /login or set an API key environment variable."; } } let thinkingLevel = options.thinkingLevel; // If session has data, restore thinking level from it if (thinkingLevel === undefined && hasExistingSession) { thinkingLevel = existingSession.thinkingLevel as ThinkingLevel; } // Fall back to settings default if (thinkingLevel === undefined) { thinkingLevel = settingsManager.getDefaultThinkingLevel() ?? "off"; } // Clamp to model capabilities if (!model || !model.reasoning) { thinkingLevel = "off"; } let skills: Skill[]; let skillWarnings: SkillWarning[]; if (options.skills !== undefined) { skills = options.skills; skillWarnings = []; } else { const discovered = discoverSkills(cwd, agentDir, settingsManager.getSkillsSettings()); skills = discovered.skills; skillWarnings = discovered.warnings; } time("discoverSkills"); const contextFiles = options.contextFiles ?? discoverContextFiles(cwd, agentDir); time("discoverContextFiles"); const autoResizeImages = settingsManager.getImageAutoResize(); // Create ALL built-in tools for the registry (extensions can enable any of them) const allBuiltInToolsMap = createAllTools(cwd, { read: { autoResizeImages } }); // Determine initially active built-in tools (default: read, bash, edit, write) const defaultActiveToolNames: ToolName[] = ["read", "bash", "edit", "write"]; const initialActiveToolNames: ToolName[] = options.tools ? options.tools.map((t) => t.name).filter((n): n is ToolName => n in allBuiltInToolsMap) : defaultActiveToolNames; const initialActiveBuiltInTools = initialActiveToolNames.map((name) => allBuiltInToolsMap[name]); time("createAllTools"); // Load extensions (discovers from standard locations + configured paths) let extensionsResult: LoadExtensionsResult; if (options.preloadedExtensions !== undefined) { // Use pre-loaded extensions (from early CLI flag discovery) extensionsResult = options.preloadedExtensions; } else if (options.extensions !== undefined) { // User explicitly provided extensions array (even if empty) - skip discovery // Create runtime for inline extensions const runtime = createExtensionRuntime(); extensionsResult = { extensions: [], errors: [], runtime, }; } else { // Discover extensions, merging with additional paths const configuredPaths = [...settingsManager.getExtensionPaths(), ...(options.additionalExtensionPaths ?? [])]; extensionsResult = await discoverAndLoadExtensions(configuredPaths, cwd, agentDir, eventBus); time("discoverAndLoadExtensions"); for (const { path, error } of extensionsResult.errors) { console.error(`Failed to load extension "${path}": ${error}`); } } // Load inline extensions from factories if (options.extensions && options.extensions.length > 0) { for (let i = 0; i < options.extensions.length; i++) { const factory = options.extensions[i]; const loaded = await loadExtensionFromFactory( factory, cwd, eventBus, extensionsResult.runtime, ``, ); extensionsResult.extensions.push(loaded); } } // Create extension runner if we have extensions or SDK custom tools // The runner provides consistent context for tool execution (shutdown, abort, etc.) let extensionRunner: ExtensionRunner | undefined; const hasExtensions = extensionsResult.extensions.length > 0; const hasCustomTools = options.customTools && options.customTools.length > 0; if (hasExtensions || hasCustomTools) { extensionRunner = new ExtensionRunner( extensionsResult.extensions, extensionsResult.runtime, cwd, sessionManager, modelRegistry, ); } // Wrap extension-registered tools and SDK-provided custom tools // Tools use runner.createContext() for consistent context with event handlers let agent: Agent; const registeredTools = extensionRunner?.getAllRegisteredTools() ?? []; // Combine extension-registered tools with SDK-provided custom tools const allCustomTools = [ ...registeredTools, ...(options.customTools?.map((def) => ({ definition: def, extensionPath: "" })) ?? []), ]; // Wrap tools using runner's context (ensures shutdown, abort, etc. work correctly) const wrappedExtensionTools = extensionRunner ? wrapRegisteredTools(allCustomTools, extensionRunner) : []; // Create tool registry mapping name -> tool (for extension getTools/setTools) // Registry contains ALL built-in tools so extensions can enable any of them const toolRegistry = new Map(); for (const [name, tool] of Object.entries(allBuiltInToolsMap)) { toolRegistry.set(name, tool as AgentTool); } for (const tool of wrappedExtensionTools as AgentTool[]) { toolRegistry.set(tool.name, tool); } // Initially active tools = active built-in + extension tools // Extension tools can override built-in tools with the same name const extensionToolNames = new Set(wrappedExtensionTools.map((t) => t.name)); const nonOverriddenBuiltInTools = initialActiveBuiltInTools.filter((t) => !extensionToolNames.has(t.name)); let activeToolsArray: Tool[] = [...nonOverriddenBuiltInTools, ...wrappedExtensionTools]; time("combineTools"); // Wrap tools with extensions if available let wrappedToolRegistry: Map | undefined; if (extensionRunner) { activeToolsArray = wrapToolsWithExtensions(activeToolsArray as AgentTool[], extensionRunner); // Wrap ALL registry tools (not just active) so extensions can enable any const allRegistryTools = Array.from(toolRegistry.values()); const wrappedAllTools = wrapToolsWithExtensions(allRegistryTools, extensionRunner); wrappedToolRegistry = new Map(); for (const tool of wrappedAllTools) { wrappedToolRegistry.set(tool.name, tool); } } // Function to rebuild system prompt when tools change // Captures static options (cwd, agentDir, skills, contextFiles, customPrompt) const rebuildSystemPrompt = (toolNames: string[]): string => { // Filter to valid tool names const validToolNames = toolNames.filter((n): n is ToolName => n in allBuiltInToolsMap); const defaultPrompt = buildSystemPromptInternal({ cwd, agentDir, skills, contextFiles, selectedTools: validToolNames, }); if (options.systemPrompt === undefined) { return defaultPrompt; } else if (typeof options.systemPrompt === "string") { // String is a full replacement - use as-is without appending context/skills return options.systemPrompt; } else { return options.systemPrompt(defaultPrompt); } }; const systemPrompt = rebuildSystemPrompt(initialActiveToolNames); time("buildSystemPrompt"); const promptTemplates = options.promptTemplates ?? discoverPromptTemplates(cwd, agentDir); time("discoverPromptTemplates"); // Create convertToLlm wrapper that filters images if blockImages is enabled (defense-in-depth) const convertToLlmWithBlockImages = (messages: AgentMessage[]): Message[] => { const converted = convertToLlm(messages); // Check setting dynamically so mid-session changes take effect if (!settingsManager.getBlockImages()) { return converted; } // Filter out ImageContent from all messages, replacing with text placeholder return converted.map((msg) => { if (msg.role === "user" || msg.role === "toolResult") { const content = msg.content; if (Array.isArray(content)) { const hasImages = content.some((c) => c.type === "image"); if (hasImages) { const filteredContent = content .map((c) => c.type === "image" ? { type: "text" as const, text: "Image reading is disabled." } : c, ) .filter( (c, i, arr) => // Dedupe consecutive "Image reading is disabled." texts !( c.type === "text" && c.text === "Image reading is disabled." && i > 0 && arr[i - 1].type === "text" && (arr[i - 1] as { type: "text"; text: string }).text === "Image reading is disabled." ), ); return { ...msg, content: filteredContent }; } } } return msg; }); }; agent = new Agent({ initialState: { systemPrompt, model, thinkingLevel, tools: activeToolsArray, }, convertToLlm: convertToLlmWithBlockImages, sessionId: sessionManager.getSessionId(), transformContext: extensionRunner ? async (messages) => { return extensionRunner.emitContext(messages); } : undefined, steeringMode: settingsManager.getSteeringMode(), followUpMode: settingsManager.getFollowUpMode(), thinkingBudgets: settingsManager.getThinkingBudgets(), getApiKey: async (provider) => { // Use the provider argument from the in-flight request; // agent.state.model may already be switched mid-turn. const resolvedProvider = provider || agent.state.model?.provider; if (!resolvedProvider) { throw new Error("No model selected"); } const key = await modelRegistry.getApiKeyForProvider(resolvedProvider); if (!key) { throw new Error(`No API key found for provider "${resolvedProvider}"`); } return key; }, }); time("createAgent"); // Restore messages if session has existing data if (hasExistingSession) { agent.replaceMessages(existingSession.messages); } else { // Save initial model and thinking level for new sessions so they can be restored on resume if (model) { sessionManager.appendModelChange(model.provider, model.id); } sessionManager.appendThinkingLevelChange(thinkingLevel); } const session = new AgentSession({ agent, sessionManager, settingsManager, scopedModels: options.scopedModels, promptTemplates: promptTemplates, extensionRunner, skills, skillWarnings, skillsSettings: settingsManager.getSkillsSettings(), modelRegistry, toolRegistry: wrappedToolRegistry ?? toolRegistry, rebuildSystemPrompt, }); time("createAgentSession"); return { session, extensionsResult, modelFallbackMessage, }; }