import { join } from "node:path"; import { Agent, type AgentMessage, type ThinkingLevel } from "@mariozechner/pi-agent-core"; import type { Message, Model } from "@mariozechner/pi-ai"; import { getAgentDir, getAuthPath } from "../config.js"; import { AgentSession } from "./agent-session.js"; import { AuthStorage } from "./auth-storage.js"; import type { ExtensionRunner, LoadExtensionsResult, ToolDefinition } from "./extensions/index.js"; import { convertToLlm } from "./messages.js"; import { ModelRegistry } from "./model-registry.js"; import type { ResourceLoader } from "./resource-loader.js"; import { DefaultResourceLoader } from "./resource-loader.js"; import { SessionManager } from "./session-manager.js"; import { SettingsManager } from "./settings-manager.js"; import { time } from "./timings.js"; import { allTools, bashTool, codingTools, createBashTool, createCodingTools, createEditTool, createFindTool, createGrepTool, createLsTool, createReadOnlyTools, createReadTool, createWriteTool, editTool, findTool, grepTool, lsTool, readOnlyTools, readTool, type Tool, type ToolName, writeTool, } from "./tools/index.js"; 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: new AuthStorage(agentDir/auth.json) */ authStorage?: AuthStorage; /** Model registry. Default: new ModelRegistry(authStorage, agentDir/models.json) */ 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 }>; /** 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[]; /** Resource loader. When omitted, DefaultResourceLoader is used. */ resourceLoader?: ResourceLoader; /** 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 { 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(); } /** * 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 loader = new DefaultResourceLoader({ * cwd: process.cwd(), * agentDir: getAgentDir(), * settingsManager: SettingsManager.create(), * }); * await loader.reload(); * const { session } = await createAgentSession({ * model: myModel, * tools: [readTool, bashTool], * resourceLoader: loader, * sessionManager: SessionManager.inMemory(), * }); * ``` */ export async function createAgentSession(options: CreateAgentSessionOptions = {}): Promise { const cwd = options.cwd ?? process.cwd(); const agentDir = options.agentDir ?? getDefaultAgentDir(); let resourceLoader = options.resourceLoader; // Use provided or create AuthStorage and ModelRegistry const authPath = options.agentDir ? join(agentDir, "auth.json") : undefined; const modelsPath = options.agentDir ? join(agentDir, "models.json") : undefined; const authStorage = options.authStorage ?? new AuthStorage(authPath); const modelRegistry = options.modelRegistry ?? new ModelRegistry(authStorage, modelsPath); const settingsManager = options.settingsManager ?? SettingsManager.create(cwd, agentDir); const sessionManager = options.sessionManager ?? SessionManager.create(cwd); if (!resourceLoader) { resourceLoader = new DefaultResourceLoader({ cwd, agentDir, settingsManager }); await resourceLoader.reload(); time("resourceLoader.reload"); } // Check if session has existing data to restore const existingSession = sessionManager.buildSessionContext(); 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; } } 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, set an API key environment variable, or create ${getAuthPath()}`; } } 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"; } 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 allTools) : defaultActiveToolNames; let agent: Agent; // 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; }); }; const extensionRunnerRef: { current?: ExtensionRunner } = {}; agent = new Agent({ initialState: { systemPrompt: "", model, thinkingLevel, tools: [], }, convertToLlm: convertToLlmWithBlockImages, sessionId: sessionManager.getSessionId(), transformContext: async (messages) => { const runner = extensionRunnerRef.current; if (!runner) return messages; return runner.emitContext(messages); }, 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) { const model = agent.state.model; const isOAuth = model && modelRegistry.isUsingOAuth(model); if (isOAuth) { throw new Error( `Authentication failed for "${resolvedProvider}". ` + `Credentials may have expired or network is unavailable. ` + `Run '/login ${resolvedProvider}' to re-authenticate.`, ); } throw new Error( `No API key found for "${resolvedProvider}". ` + `Set an API key environment variable or run '/login ${resolvedProvider}'.`, ); } return key; }, }); // 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, cwd, scopedModels: options.scopedModels, resourceLoader, customTools: options.customTools, modelRegistry, initialActiveToolNames, extensionRunnerRef, }); const extensionsResult = resourceLoader.getExtensions(); return { session, extensionsResult, modelFallbackMessage, }; }