diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 186a3b3c2..67e1b6731 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] + ### Added - Added `omp commit` command to generate commit messages and update changelogs with `--push`, `--dry-run`, `--no-changelog`, and model override flags @@ -22,6 +23,11 @@ ### Changed +- Renamed web search types and exports for consistency: `WebSearchProvider` → `SearchProviderId`, `WebSearchResponse` → `SearchResponse`, `WebSearchTool` → `SearchTool`, and related functions +- Refactored web search provider system to use centralized provider registry with `getSearchProvider()` and `resolveProviderChain()` for improved provider management +- Updated web search system prompt to emphasize comprehensive, detailed answers with concrete data and specific examples over brevity +- Simplified Exa API key discovery to check environment variables only, removing .env file fallback logic +- Refactored `ModelRegistry` instantiation to use direct constructor instead of `discoverModels()` helper function across codebase - Refactored CLI entry point to use oclif command framework instead of custom subcommand routing - Reorganized subcommands into individual command files under `src/commands/` directory for better maintainability - Updated extension flag handling to parse raw arguments directly instead of using custom flag definitions @@ -35,6 +41,7 @@ ### Fixed +- Fixed type handling in model selector error message display to properly convert error objects to strings - Fixed web search to use search results when Perplexity API returns no citations, ensuring search results are always available to users - Fixed model switches deferred during streaming to apply correctly when the stream completes, preventing model changes from being lost - Fixed plan mode toggles during streaming to inject plan-mode context immediately, preventing file edits while in plan mode diff --git a/packages/coding-agent/DEVELOPMENT.md b/packages/coding-agent/DEVELOPMENT.md index 9481166e9..395a72bb9 100644 --- a/packages/coding-agent/DEVELOPMENT.md +++ b/packages/coding-agent/DEVELOPMENT.md @@ -316,7 +316,7 @@ src/ │ ├── utils.ts # Scraper utilities │ └── (domain-specific scrapers) └── search/ # Web search - ├── index.ts # WebSearchTool exports + ├── index.ts # SearchTool exports ├── auth.ts # Search auth ├── render.ts # Search result rendering └── providers/ # Search providers diff --git a/packages/coding-agent/src/cli/web-search-cli.ts b/packages/coding-agent/src/cli/web-search-cli.ts index 7df07d265..1b791e0b4 100644 --- a/packages/coding-agent/src/cli/web-search-cli.ts +++ b/packages/coding-agent/src/cli/web-search-cli.ts @@ -6,19 +6,19 @@ import chalk from "chalk"; import { APP_NAME } from "../config"; import { initTheme, theme } from "../modes/theme/theme"; -import { renderWebSearchResult } from "../web/search/render"; -import { runWebSearchQuery, type WebSearchParams } from "../web/search/index"; -import type { WebSearchProvider } from "../web/search/types"; +import { runSearchQuery, type SearchParams } from "../web/search/index"; +import { renderSearchResult } from "../web/search/render"; +import type { SearchProviderId } from "../web/search/types"; -export interface WebSearchCommandArgs { +export interface SearchCommandArgs { query: string; - provider?: WebSearchProvider | "auto"; + provider?: SearchProviderId | "auto"; recency?: "day" | "week" | "month" | "year"; limit?: number; expanded: boolean; } -const PROVIDERS: Array = [ +const PROVIDERS: Array = [ "auto", "anthropic", "perplexity", @@ -28,18 +28,18 @@ const PROVIDERS: Array = [ "codex", ]; -const RECENCY_OPTIONS: WebSearchCommandArgs["recency"][] = ["day", "week", "month", "year"]; +const RECENCY_OPTIONS: SearchCommandArgs["recency"][] = ["day", "week", "month", "year"]; /** * Parse web search subcommand arguments. * Returns undefined if not a web search command. */ -export function parseWebSearchArgs(args: string[]): WebSearchCommandArgs | undefined { +export function parseSearchArgs(args: string[]): SearchCommandArgs | undefined { if (args.length === 0 || (args[0] !== "q" && args[0] !== "web-search")) { return undefined; } - const result: WebSearchCommandArgs = { + const result: SearchCommandArgs = { query: "", expanded: true, }; @@ -49,9 +49,9 @@ export function parseWebSearchArgs(args: string[]): WebSearchCommandArgs | undef for (let i = 1; i < args.length; i++) { const arg = args[i]; if (arg === "--provider") { - result.provider = args[++i] as WebSearchCommandArgs["provider"]; + result.provider = args[++i] as SearchCommandArgs["provider"]; } else if (arg === "--recency") { - result.recency = args[++i] as WebSearchCommandArgs["recency"]; + result.recency = args[++i] as SearchCommandArgs["recency"]; } else if (arg === "--limit" || arg === "-l") { result.limit = Number.parseInt(args[++i], 10); } else if (arg === "--compact") { @@ -68,7 +68,7 @@ export function parseWebSearchArgs(args: string[]): WebSearchCommandArgs | undef return result; } -export async function runWebSearchCommand(cmd: WebSearchCommandArgs): Promise { +export async function runSearchCommand(cmd: SearchCommandArgs): Promise { if (!cmd.query) { writeStderr(chalk.red("Error: Query is required")); process.exit(1); @@ -93,15 +93,15 @@ export async function runWebSearchCommand(cmd: WebSearchCommandArgs): Promise { const { args, flags } = await this.parse(Grep); - const mode: GrepCommandArgs["mode"] = flags.count - ? "count" - : flags.files - ? "filesWithMatches" - : "content"; + const mode: GrepCommandArgs["mode"] = flags.count ? "count" : flags.files ? "filesWithMatches" : "content"; const cmd: GrepCommandArgs = { pattern: args.pattern ?? "", diff --git a/packages/coding-agent/src/commands/index.ts b/packages/coding-agent/src/commands/index/index.ts similarity index 94% rename from packages/coding-agent/src/commands/index.ts rename to packages/coding-agent/src/commands/index/index.ts index 4ee6b875d..eacdb591e 100644 --- a/packages/coding-agent/src/commands/index.ts +++ b/packages/coding-agent/src/commands/index/index.ts @@ -2,12 +2,13 @@ * Root command for the coding agent CLI. */ import { Args, Command, Flags } from "@oclif/core"; -import { parseArgs } from "../cli/args"; -import { APP_NAME } from "../config"; -import { runRootCommand } from "../main"; +import { parseArgs } from "../../cli/args"; +import { APP_NAME } from "../../config"; +import { runRootCommand } from "../../main"; export default class Index extends Command { static description = "AI coding assistant"; + static hidden = true; static args = { messages: Args.string({ @@ -19,7 +20,7 @@ export default class Index extends Command { static flags = { model: Flags.string({ - description: "Model to use (fuzzy match: \"opus\", \"gpt-5.2\", or \"p-openai/gpt-5.2\")", + description: 'Model to use (fuzzy match: "opus", "gpt-5.2", or "p-openai/gpt-5.2")', }), smol: Flags.string({ description: "Smol/fast model for lightweight tasks (or PI_SMOL_MODEL env)", diff --git a/packages/coding-agent/src/commands/plugin.ts b/packages/coding-agent/src/commands/plugin.ts index 20ffc1992..a93b1b7a3 100644 --- a/packages/coding-agent/src/commands/plugin.ts +++ b/packages/coding-agent/src/commands/plugin.ts @@ -2,7 +2,7 @@ * Manage plugins (install, uninstall, list, etc.). */ import { Args, Command, Flags } from "@oclif/core"; -import { type PluginCommandArgs, type PluginAction, runPluginCommand } from "../cli/plugin-cli"; +import { type PluginAction, type PluginCommandArgs, runPluginCommand } from "../cli/plugin-cli"; import { initTheme } from "../modes/theme/theme"; const ACTIONS: PluginAction[] = [ @@ -48,9 +48,10 @@ export default class Plugin extends Command { const { args, flags } = await this.parse(Plugin); const action = (args.action ?? "list") as PluginAction; + const targets = Array.isArray(args.targets) ? args.targets : args.targets ? [args.targets] : []; const cmd: PluginCommandArgs = { action, - args: args.targets ?? [], + args: targets, flags: { json: flags.json, fix: flags.fix, diff --git a/packages/coding-agent/src/commands/setup.ts b/packages/coding-agent/src/commands/setup.ts index 0ea308499..b11b16d6c 100644 --- a/packages/coding-agent/src/commands/setup.ts +++ b/packages/coding-agent/src/commands/setup.ts @@ -2,7 +2,7 @@ * Install dependencies for optional features. */ import { Args, Command, Flags } from "@oclif/core"; -import { type SetupCommandArgs, type SetupComponent, runSetupCommand } from "../cli/setup-cli"; +import { runSetupCommand, type SetupCommandArgs, type SetupComponent } from "../cli/setup-cli"; import { initTheme } from "../modes/theme/theme"; const COMPONENTS: SetupComponent[] = ["python"]; diff --git a/packages/coding-agent/src/commands/shell.ts b/packages/coding-agent/src/commands/shell.ts index 87867df31..071aae23d 100644 --- a/packages/coding-agent/src/commands/shell.ts +++ b/packages/coding-agent/src/commands/shell.ts @@ -2,7 +2,7 @@ * Interactive shell console. */ import { Command, Flags } from "@oclif/core"; -import { type ShellCommandArgs, runShellCommand } from "../cli/shell-cli"; +import { runShellCommand, type ShellCommandArgs } from "../cli/shell-cli"; import { initTheme } from "../modes/theme/theme"; export default class Shell extends Command { diff --git a/packages/coding-agent/src/commands/stats.ts b/packages/coding-agent/src/commands/stats.ts index 16ca97a52..549542d0b 100644 --- a/packages/coding-agent/src/commands/stats.ts +++ b/packages/coding-agent/src/commands/stats.ts @@ -2,7 +2,7 @@ * View usage statistics dashboard. */ import { Command, Flags } from "@oclif/core"; -import { type StatsCommandArgs, runStatsCommand } from "../cli/stats-cli"; +import { runStatsCommand, type StatsCommandArgs } from "../cli/stats-cli"; import { initTheme } from "../modes/theme/theme"; export default class Stats extends Command { diff --git a/packages/coding-agent/src/commands/web-search.ts b/packages/coding-agent/src/commands/web-search.ts index b21084960..fbf5d356c 100644 --- a/packages/coding-agent/src/commands/web-search.ts +++ b/packages/coding-agent/src/commands/web-search.ts @@ -2,10 +2,10 @@ * Test web search providers. */ import { Args, Command, Flags } from "@oclif/core"; -import { type WebSearchCommandArgs, runWebSearchCommand } from "../cli/web-search-cli"; -import type { WebSearchProvider } from "../web/search/types"; +import { runSearchCommand, type SearchCommandArgs } from "../cli/web-search-cli"; +import type { SearchProviderId } from "../web/search/types"; -const PROVIDERS: Array = [ +const PROVIDERS: Array = [ "auto", "anthropic", "perplexity", @@ -15,9 +15,9 @@ const PROVIDERS: Array = [ "codex", ]; -const RECENCY: NonNullable[] = ["day", "week", "month", "year"]; +const RECENCY: NonNullable[] = ["day", "week", "month", "year"]; -export default class WebSearch extends Command { +export default class Search extends Command { static description = "Test web search providers"; static aliases = ["q"]; @@ -34,17 +34,17 @@ export default class WebSearch extends Command { }; async run(): Promise { - const { args, flags } = await this.parse(WebSearch); - const query = Array.isArray(args.query) ? args.query.join(" ") : args.query ?? ""; + const { args, flags } = await this.parse(Search); + const query = Array.isArray(args.query) ? args.query.join(" ") : (args.query ?? ""); - const cmd: WebSearchCommandArgs = { + const cmd: SearchCommandArgs = { query, - provider: flags.provider as WebSearchProvider | "auto" | undefined, - recency: flags.recency as WebSearchCommandArgs["recency"], + provider: flags.provider as SearchProviderId | "auto" | undefined, + recency: flags.recency as SearchCommandArgs["recency"], limit: flags.limit, expanded: !flags.compact, }; - await runWebSearchCommand(cmd); + await runSearchCommand(cmd); } } diff --git a/packages/coding-agent/src/commit/agentic/index.ts b/packages/coding-agent/src/commit/agentic/index.ts index a5f0ec7f0..6fca1b09c 100644 --- a/packages/coding-agent/src/commit/agentic/index.ts +++ b/packages/coding-agent/src/commit/agentic/index.ts @@ -8,9 +8,10 @@ import { ControlledGit } from "../../commit/git"; import { formatCommitMessage } from "../../commit/message"; import { resolvePrimaryModel, resolveSmolModel } from "../../commit/model-selection"; import type { CommitCommandArgs, ConventionalAnalysis } from "../../commit/types"; +import { ModelRegistry } from "../../config/model-registry"; import { renderPromptTemplate } from "../../config/prompt-templates"; import { Settings } from "../../config/settings"; -import { discoverAuthStorage, discoverContextFiles, discoverModels } from "../../sdk"; +import { discoverAuthStorage, discoverContextFiles } from "../../sdk"; import { type ExistingChangelogEntries, runCommitAgentSession } from "./agent"; import { generateFallbackProposal } from "./fallback"; import splitConfirmPrompt from "./prompts/split-confirm.md" with { type: "text" }; @@ -31,7 +32,7 @@ export async function runAgenticCommit(args: CommitCommandArgs): Promise { const settings = settingsInstance; writeStdout("● Resolving model..."); - const modelRegistry = discoverModels(authStorage); + const modelRegistry = new ModelRegistry(authStorage); const stagedFilesPromise = (async () => { let stagedFiles = await git.getStagedFiles(); if (stagedFiles.length === 0) { diff --git a/packages/coding-agent/src/commit/pipeline.ts b/packages/coding-agent/src/commit/pipeline.ts index 953099220..96ad355ac 100644 --- a/packages/coding-agent/src/commit/pipeline.ts +++ b/packages/coding-agent/src/commit/pipeline.ts @@ -1,9 +1,10 @@ import * as path from "node:path"; import type { Api, Model } from "@oh-my-pi/pi-ai"; import { logger } from "@oh-my-pi/pi-utils"; +import { ModelRegistry } from "../config/model-registry"; import { renderPromptTemplate } from "../config/prompt-templates"; import { Settings } from "../config/settings"; -import { discoverAuthStorage, discoverModels } from "../sdk"; +import { discoverAuthStorage } from "../sdk"; import { loadProjectContextFiles } from "../system-prompt"; import { runAgenticCommit } from "./agentic"; import { @@ -41,7 +42,7 @@ async function runLegacyCommitCommand(args: CommitCommandArgs): Promise { const settingsInstance = await Settings.init(); const commitSettings = settingsInstance.getGroup("commit"); const authStorage = await discoverAuthStorage(); - const modelRegistry = discoverModels(authStorage); + const modelRegistry = new ModelRegistry(authStorage); const { model: primaryModel, apiKey: primaryApiKey } = await resolvePrimaryModel( args.model, diff --git a/packages/coding-agent/src/config.ts b/packages/coding-agent/src/config.ts index c3282edae..e468c6ff7 100644 --- a/packages/coding-agent/src/config.ts +++ b/packages/coding-agent/src/config.ts @@ -2,6 +2,10 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { $env, isEnoent, logger } from "@oh-my-pi/pi-utils"; +import type { TSchema } from "@sinclair/typebox"; +import { Value } from "@sinclair/typebox/value/index.mjs"; +import { Ajv, type ErrorObject, type ValidateFunction } from "ajv"; +import { JSONC, TOML, YAML } from "bun"; // Embed package.json at build time for config import packageJson from "../package.json" with { type: "json" }; @@ -51,6 +55,195 @@ export function getChangelogPath(): string { // User Config Paths (~/.omp/agent/*) // ============================================================================= +function migrateJsonToYml(jsonPath: string, ymlPath: string) { + try { + if (fs.existsSync(ymlPath)) return; + if (!fs.existsSync(jsonPath)) return; + + const content = fs.readFileSync(jsonPath, "utf-8"); + const parsed = JSON.parse(content); + if (!parsed) { + logger.warn("migrateJsonToYml: invalid json structure", { path: jsonPath }); + return; + } + fs.writeFileSync(ymlPath, YAML.stringify(parsed, null, 2)); + } catch (error) { + logger.warn("migrateJsonToYml: migration failed", { error: String(error) }); + } +} + +export interface IConfigFile { + readonly id: string; + readonly schema: TSchema; + path?(): string; + load(): T | null; + invalidate?(): void; +} + +export class ConfigError extends Error { + readonly #message: string; + constructor( + public readonly id: string, + public readonly schemaErrors: ErrorObject[] | null | undefined, + public readonly other?: { err: unknown; stage: string }, + ) { + let messages: string[] | undefined; + let cause: any | undefined; + let klass: string; + + if (schemaErrors) { + klass = "Schema"; + messages = schemaErrors.map(e => `${e.instancePath || "root"}: ${e.message}`); + } else if (other) { + klass = other.stage; + if (other.err instanceof Error) { + messages = [other.err.message]; + cause = other.err; + } else { + messages = [String(other.err)]; + } + } else { + klass = "Unknown"; + } + + const title = `Failed to load config file ${id}, ${klass} error:`; + let message: string; + switch (messages?.length ?? 0) { + case 0: + message = title.slice(0, -1); + break; + case 1: + message = `${title} ${messages![0]}`; + break; + default: + message = `${title}\n${messages!.map(m => ` - ${m}`).join("\n")}`; + break; + } + + super(message, { cause }); + this.name = "LoadError"; + this.#message = message; + } + + get message(): string { + return this.#message; + } + + toString(): string { + return this.message; + } +} + +export type LoadStatus = "ok" | "error" | "not-found"; + +export type LoadResult = + | { value?: null; error: ConfigError; status: "error" } + | { value: T; error?: undefined; status: "ok" } + | { value?: null; error?: unknown; status: "not-found" }; + +const ajv = new Ajv(); +export class ConfigFile implements IConfigFile { + readonly #basePath: string; + #cache?: LoadResult; + #auxValidate?: (value: T) => void; + + constructor( + public readonly id: string, + public readonly schema: TSchema, + configPath: string = path.join(getAgentDir(), `${id}.yml`), + ) { + this.#basePath = configPath; + if (configPath.endsWith(".yml")) { + const jsonPath = `${configPath.slice(0, -4)}.json`; + migrateJsonToYml(jsonPath, configPath); + } else if (configPath.endsWith(".yaml")) { + const jsonPath = `${configPath.slice(0, -5)}.json`; + migrateJsonToYml(jsonPath, configPath); + } else { + throw new Error(`Invalid config file path: ${configPath}`); + } + } + + relocate(path?: string): ConfigFile { + if (!path || path === this.#basePath) return this; + const result = new ConfigFile(this.id, this.schema, path); + result.#auxValidate = this.#auxValidate; + return result; + } + + withValidation(name: string, validate: (value: T) => void): this { + const prev = this.#auxValidate; + this.#auxValidate = (value: T) => { + prev?.(value); + try { + validate(value); + } catch (error) { + throw new ConfigError(this.id, undefined, { err: error, stage: `Validate(${name})` }); + } + }; + return this; + } + + createDefault() { + return Value.Default(this.schema, [], undefined) as T; + } + + #storeCache(result: LoadResult): LoadResult { + this.#cache = result; + return result; + } + + tryLoad(): LoadResult { + if (this.#cache) return this.#cache; + + try { + const content = fs.readFileSync(this.path(), "utf-8").trim(); + + let parsed: unknown; + if (this.#basePath.endsWith(".json") || this.#basePath.endsWith(".jsonc")) { + parsed = JSONC.parse(content); + } else if (this.#basePath.endsWith(".yml") || this.#basePath.endsWith(".yaml")) { + parsed = YAML.parse(content); + } else { + throw new Error(`Invalid config file path: ${this.#basePath}`); + } + + const validate = ajv.compile(this.schema) as ValidateFunction; + if (!validate(parsed)) { + const error = new ConfigError(this.id, validate.errors); + logger.warn("Failed to parse config file", { path: this.path(), error }); + return this.#storeCache({ error, status: "error" }); + } + return this.#storeCache({ value: parsed, status: "ok" }); + } catch (error) { + if (!isEnoent(error)) { + return this.#storeCache({ status: "not-found" }); + } + logger.warn("Failed to parse config file", { path: this.path(), error }); + return this.#storeCache({ + error: new ConfigError(this.id, undefined, { err: error, stage: "Unexpected" }), + status: "error", + }); + } + } + + load(): T | null { + return this.tryLoad().value ?? null; + } + + loadOrDefault(): T { + return this.tryLoad().value ?? this.createDefault(); + } + + path(): string { + return this.#basePath; + } + + invalidate() { + this.#cache = undefined; + } +} + /** Get the agent config directory (e.g., ~/.omp/agent/) */ export function getAgentDir(): string { return $env.PI_CODING_AGENT_DIR || path.join(os.homedir(), CONFIG_DIR_NAME, "agent"); @@ -61,16 +254,6 @@ export function getCustomThemesDir(): string { return path.join(getAgentDir(), "themes"); } -/** Get path to models.json */ -export function getModelsPath(): string { - return path.join(getAgentDir(), "models.json"); -} - -/** Get path to models.yml (preferred over models.json) */ -export function getModelsYamlPath(): string { - return path.join(getAgentDir(), "models.yml"); -} - /** Get path to auth.json */ export function getAuthPath(): string { return path.join(getAgentDir(), "auth.json"); @@ -204,75 +387,6 @@ export interface ConfigFileResult { content: T; } -/** - * Read the first existing config file from priority-ordered locations. - * - * @param subpath - Subpath within config dirs (e.g., "settings.json", "models.json") - * @param options - Options for filtering (same as getConfigDirs) - * @returns The parsed content and metadata, or undefined if not found - * - * @example - * const result = readConfigFile("settings.json", { project: false }); - * if (result) { - * console.log(`Loaded from ${result.path}`); - * console.log(result.content); - * } - */ -export async function readConfigFile( - subpath: string, - options: GetConfigDirsOptions = {}, -): Promise | undefined> { - const dirs = getConfigDirs("", { ...options, existingOnly: false }); - - for (const { path: base, source, level } of dirs) { - const filePath = path.join(base, subpath); - try { - const content = await Bun.file(filePath).text(); - return { - path: filePath, - source, - level, - content: JSON.parse(content) as T, - }; - } catch (error) { - if (isEnoent(error)) continue; - logger.warn("Failed to parse config file", { path: filePath, error: String(error) }); - } - } - - return undefined; -} - -/** - * Get all existing config files for a subpath (for merging scenarios). - * Returns in priority order (highest first). - */ -export async function readAllConfigFiles( - subpath: string, - options: GetConfigDirsOptions = {}, -): Promise[]> { - const dirs = getConfigDirs("", { ...options, existingOnly: false }); - const results: ConfigFileResult[] = []; - - for (const { path: base, source, level } of dirs) { - const filePath = path.join(base, subpath); - try { - const content = await Bun.file(filePath).text(); - results.push({ - path: filePath, - source, - level, - content: JSON.parse(content) as T, - }); - } catch (error) { - if (isEnoent(error)) continue; - logger.warn("Failed to parse config file", { path: filePath, error: String(error) }); - } - } - - return results; -} - /** * Find the first existing config file (for non-JSON files like SYSTEM.md). * Returns just the path, or undefined if not found. @@ -313,55 +427,12 @@ export function findConfigFileWithMeta( // Walk-Up Config Discovery (for monorepo scenarios) // ============================================================================= -async function isDirectory(p: string): Promise { - try { - return (await fs.promises.stat(p)).isDirectory(); - } catch { - return false; - } -} - -/** - * Find nearest config directory by walking up from cwd. - * Checks all config bases (.omp, .pi, .claude) at each level. - * - * @param subpath - Subpath within config dirs (e.g., "commands", "agents") - * @param cwd - Starting directory - * @returns First existing directory found, or undefined - */ -export async function findNearestProjectConfigDir( - subpath: string, - cwd: string = process.cwd(), -): Promise { - let currentDir = cwd; - - while (true) { - // Check all config bases at this level, in priority order - for (const { base, name } of PROJECT_CONFIG_BASES) { - const candidate = path.join(currentDir, base, subpath); - if (await isDirectory(candidate)) { - return { path: candidate, source: name, level: "project" }; - } - } - - // Move up one directory - const parentDir = path.dirname(currentDir); - if (parentDir === currentDir) break; // Reached root - currentDir = parentDir; - } - - return undefined; -} - /** * Find all nearest config directories by walking up from cwd. * Returns one entry per config base (.omp, .pi, .claude) - the nearest one found. * Results are in priority order (highest first). */ -export async function findAllNearestProjectConfigDirs( - subpath: string, - cwd: string = process.cwd(), -): Promise { +export function findAllNearestProjectConfigDirs(subpath: string, cwd: string = process.cwd()): ConfigDirEntry[] { const results: ConfigDirEntry[] = []; const foundBases = new Set(); @@ -372,10 +443,12 @@ export async function findAllNearestProjectConfigDirs( if (foundBases.has(name)) continue; const candidate = path.join(currentDir, base, subpath); - if (await isDirectory(candidate)) { - results.push({ path: candidate, source: name, level: "project" }); - foundBases.add(name); - } + try { + if (fs.statSync(candidate).isDirectory()) { + results.push({ path: candidate, source: name, level: "project" }); + foundBases.add(name); + } + } catch {} } const parentDir = path.dirname(currentDir); diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index aa18e3fa3..232d14415 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -1,8 +1,3 @@ -/** - * Model registry - manages built-in and custom models, provides API key resolution. - */ -import * as fs from "node:fs"; -import * as path from "node:path"; import { type Api, getGitHubCopilotBaseUrl, @@ -11,10 +6,9 @@ import { type Model, normalizeDomain, } from "@oh-my-pi/pi-ai"; -import { isEnoent, logger } from "@oh-my-pi/pi-utils"; +import { type ConfigError, ConfigFile } from "@oh-my-pi/pi-coding-agent/config"; import { type Static, Type } from "@sinclair/typebox"; import AjvModule from "ajv"; -import { YAML } from "bun"; import type { ThemeColor } from "../modes/theme/theme"; import type { AuthStorage } from "../session/auth-storage"; @@ -36,7 +30,7 @@ export const MODEL_ROLES: Record = { export const MODEL_ROLE_IDS: ModelRole[] = ["default", "smol", "slow", "plan", "commit"]; -const Ajv = (AjvModule as any).default || AjvModule; +const _Ajv = (AjvModule as any).default || AjvModule; const OpenRouterRoutingSchema = Type.Object({ only: Type.Optional(Type.Array(Type.String())), @@ -113,272 +107,9 @@ const ModelsConfigSchema = Type.Object({ type ModelsConfig = Static; -/** Provider override config (baseUrl, headers, apiKey) without custom models */ -interface ProviderOverride { - baseUrl?: string; - headers?: Record; - apiKey?: string; -} - -/** - * Serialized representation of ModelRegistry for passing to subagent workers. - */ -export interface SerializedModelRegistry { - models: Model[]; - customProviderApiKeys?: Record; - loadError?: string; -} - -/** Result of loading custom models from models.json */ -interface CustomModelsResult { - models: Model[]; - /** Providers with custom models (full replacement) */ - replacedProviders: Set; - /** Providers with only baseUrl/headers override (no custom models) */ - overrides: Map; - error: string | undefined; - /** Whether the file was found (true) or didn't exist (false) */ - found: boolean; -} - -function emptyCustomModelsResult(error?: string): CustomModelsResult { - return { models: [], replacedProviders: new Set(), overrides: new Map(), error, found: false }; -} - -/** - * Resolve an API key config value to an actual key. - * Checks environment variable first, then treats as literal. - */ -function resolveApiKeyConfig(keyConfig: string): string | undefined { - const envValue = Bun.env[keyConfig]; - if (envValue) return envValue; - return keyConfig; -} - -/** - * Model registry - loads and manages models, resolves API keys via AuthStorage. - */ -export class ModelRegistry { - private models: Model[] = []; - private customProviderApiKeys: Map = new Map(); - private loadError: string | undefined = undefined; - - /** - * @param authStorage - Auth storage for API key resolution - * @param modelsJsonPath - Primary path for models.json - * @param fallbackPaths - Additional paths to check (legacy support) - */ - constructor( - readonly authStorage: AuthStorage, - private modelsJsonPath: string | undefined = undefined, - private fallbackPaths: string[] = [], - ) { - // Set up fallback resolver for custom provider API keys - this.authStorage.setFallbackResolver(provider => { - const keyConfig = this.customProviderApiKeys.get(provider); - if (keyConfig) { - return resolveApiKeyConfig(keyConfig); - } - return undefined; - }); - // Load models synchronously in constructor - this.loadModels(); - } - - /** - * Create an in-memory ModelRegistry instance from serialized data. - * Used by subagent workers to bypass discovery and use parent's models. - */ - static fromSerialized(data: SerializedModelRegistry, authStorage: AuthStorage): ModelRegistry { - const instance = Object.create(ModelRegistry.prototype) as ModelRegistry; - (instance as any).authStorage = authStorage; - instance.models = data.models; - instance.customProviderApiKeys = new Map(Object.entries(data.customProviderApiKeys ?? {})); - instance.loadError = data.loadError; - - authStorage.setFallbackResolver(provider => { - const keyConfig = instance.customProviderApiKeys.get(provider); - if (keyConfig) { - return resolveApiKeyConfig(keyConfig); - } - return undefined; - }); - - return instance; - } - - /** - * Serialize ModelRegistry for passing to subagent workers. - */ - serialize(): SerializedModelRegistry { - const customProviderApiKeys: Record = {}; - for (const [k, v] of this.customProviderApiKeys.entries()) { - customProviderApiKeys[k] = v; - } - return { - models: this.models, - customProviderApiKeys: Object.keys(customProviderApiKeys).length > 0 ? customProviderApiKeys : undefined, - loadError: this.loadError, - }; - } - - /** - * Reload models from disk (built-in + custom from models.json). - */ - refresh(): void { - this.customProviderApiKeys.clear(); - this.loadError = undefined; - this.loadModels(); - } - - /** - * Get any error from loading models.json (undefined if no error). - */ - getError(): string | undefined { - return this.loadError; - } - - private loadModels() { - // Load custom models from models.json first (to know which providers to skip/override) - let customModels: Model[] = []; - let replacedProviders: Set = new Set(); - let overrides: Map = new Map(); - const pathsToCheck = this.modelsJsonPath ? [this.modelsJsonPath, ...this.fallbackPaths] : this.fallbackPaths; - - if (pathsToCheck.length > 0) { - logger.debug("ModelRegistry.loadModels checking paths", { paths: pathsToCheck }); - } - - for (const modelsPath of pathsToCheck) { - const result = this.loadCustomModels(modelsPath); - if (!result.found) { - continue; // File doesn't exist, try next path - } - logger.debug("ModelRegistry.loadModels loading", { path: modelsPath }); - if (result.error) { - this.loadError = result.error; - // Keep built-in models even if custom models failed to load - } else { - customModels = result.models; - replacedProviders = result.replacedProviders; - overrides = result.overrides; - } - break; // Use first existing file - } - - const builtInModels = this.loadBuiltInModels(replacedProviders, overrides); - const combined = [...builtInModels, ...customModels]; - - // Update github-copilot base URL based on OAuth credentials - const copilotCred = this.authStorage.getOAuthCredential("github-copilot"); - if (copilotCred) { - const domain = copilotCred.enterpriseUrl - ? (normalizeDomain(copilotCred.enterpriseUrl) ?? undefined) - : undefined; - const baseUrl = getGitHubCopilotBaseUrl(copilotCred.access, domain); - this.models = combined.map(m => (m.provider === "github-copilot" ? { ...m, baseUrl } : m)); - } else { - this.models = combined; - } - } - - /** Load built-in models, skipping replaced providers and applying overrides */ - private loadBuiltInModels(replacedProviders: Set, overrides: Map): Model[] { - return getProviders() - .filter(provider => !replacedProviders.has(provider)) - .flatMap(provider => { - const models = getModels(provider as any) as Model[]; - const override = overrides.get(provider); - if (!override) return models; - - // Apply baseUrl/headers override to all models of this provider - return models.map(m => ({ - ...m, - baseUrl: override.baseUrl ?? m.baseUrl, - headers: override.headers ? { ...m.headers, ...override.headers } : m.headers, - })); - }); - } - - private loadCustomModels(modelsPath: string): CustomModelsResult { - let content: string; - try { - content = fs.readFileSync(modelsPath, "utf-8"); - } catch (error) { - if (isEnoent(error)) { - return emptyCustomModelsResult(); - } - return { - ...emptyCustomModelsResult( - `Failed to load models config: ${error instanceof Error ? error.message : error}\n\nFile: ${modelsPath}`, - ), - found: true, - }; - } - - try { - const ext = path.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(); - const validate = ajv.compile(ModelsConfigSchema); - if (!validate(config)) { - const errors = - validate.errors?.map((e: any) => ` - ${e.instancePath || "root"}: ${e.message}`).join("\n") || - "Unknown schema error"; - return emptyCustomModelsResult(`Invalid models config schema:\n${errors}\n\nFile: ${modelsPath}`); - } - - // Additional validation - this.validateConfig(config); - - // Separate providers into "full replacement" (has models) vs "override-only" (no models) - const replacedProviders = new Set(); - const overrides = new Map(); - - for (const [providerName, providerConfig] of Object.entries(config.providers)) { - if (providerConfig.models && providerConfig.models.length > 0) { - // Has custom models -> full replacement - replacedProviders.add(providerName); - } else { - // No models -> just override baseUrl/headers on built-in - overrides.set(providerName, { - baseUrl: providerConfig.baseUrl, - headers: providerConfig.headers, - apiKey: providerConfig.apiKey, - }); - // Store API key for fallback resolver - if (providerConfig.apiKey) { - this.customProviderApiKeys.set(providerName, providerConfig.apiKey); - } - } - } - - return { models: this.parseModels(config), replacedProviders, overrides, error: undefined, found: true }; - } catch (error) { - if (error instanceof SyntaxError) { - return { - ...emptyCustomModelsResult(`Failed to parse models config: ${error.message}\n\nFile: ${modelsPath}`), - found: true, - }; - } - return { - ...emptyCustomModelsResult( - `Failed to load models config: ${error instanceof Error ? error.message : error}\n\nFile: ${modelsPath}`, - ), - found: true, - }; - } - } - - private validateConfig(config: ModelsConfig): void { +export const ModelsConfigFile = new ConfigFile("models", ModelsConfigSchema).withValidation( + "models", + config => { for (const [providerName, providerConfig] of Object.entries(config.providers)) { const hasProviderApi = !!providerConfig.api; const models = providerConfig.models ?? []; @@ -417,6 +148,201 @@ export class ModelRegistry { throw new Error(`Provider ${providerName}, model ${modelDef.id}: invalid maxTokens`); } } + }, +); + +/** Provider override config (baseUrl, headers, apiKey) without custom models */ +interface ProviderOverride { + baseUrl?: string; + headers?: Record; + apiKey?: string; +} + +/** + * Serialized representation of ModelRegistry for passing to subagent workers. + */ +export interface SerializedModelRegistry { + models: Model[]; + customProviderApiKeys?: Record; +} + +/** Result of loading custom models from models.json */ +interface CustomModelsResult { + models?: Model[]; + /** Providers with custom models (full replacement) */ + replacedProviders?: Set; + /** Providers with only baseUrl/headers override (no custom models) */ + overrides?: Map; + error?: ConfigError; + found: boolean; +} + +/** + * Resolve an API key config value to an actual key. + * Checks environment variable first, then treats as literal. + */ +function resolveApiKeyConfig(keyConfig: string): string | undefined { + const envValue = Bun.env[keyConfig]; + if (envValue) return envValue; + return keyConfig; +} + +/** + * Model registry - loads and manages models, resolves API keys via AuthStorage. + */ +export class ModelRegistry { + private models: Model[] = []; + private customProviderApiKeys: Map = new Map(); + private configError: ConfigError | undefined = undefined; + private modelsConfigFile: ConfigFile; + + /** + * @param authStorage - Auth storage for API key resolution + */ + constructor( + readonly authStorage: AuthStorage, + modelsPath?: string, + ) { + this.modelsConfigFile = ModelsConfigFile.relocate(modelsPath); + // Set up fallback resolver for custom provider API keys + this.authStorage.setFallbackResolver(provider => { + const keyConfig = this.customProviderApiKeys.get(provider); + if (keyConfig) { + return resolveApiKeyConfig(keyConfig); + } + return undefined; + }); + // Load models synchronously in constructor + this.loadModels(); + } + + /** + * Create an in-memory ModelRegistry instance from serialized data. + * Used by subagent workers to bypass discovery and use parent's models. + */ + static fromSerialized(data: SerializedModelRegistry, authStorage: AuthStorage): ModelRegistry { + const instance = Object.create(ModelRegistry.prototype) as ModelRegistry; + (instance as any).authStorage = authStorage; + instance.models = data.models; + instance.customProviderApiKeys = new Map(Object.entries(data.customProviderApiKeys ?? {})); + + authStorage.setFallbackResolver(provider => { + const keyConfig = instance.customProviderApiKeys.get(provider); + if (keyConfig) { + return resolveApiKeyConfig(keyConfig); + } + return undefined; + }); + + return instance; + } + + /** + * Serialize ModelRegistry for passing to subagent workers. + */ + serialize(): SerializedModelRegistry { + const customProviderApiKeys: Record = {}; + for (const [k, v] of this.customProviderApiKeys.entries()) { + customProviderApiKeys[k] = v; + } + return { + models: this.models, + customProviderApiKeys: Object.keys(customProviderApiKeys).length > 0 ? customProviderApiKeys : undefined, + }; + } + + /** + * Reload models from disk (built-in + custom from models.json). + */ + refresh(): void { + this.customProviderApiKeys.clear(); + this.configError = undefined; + this.loadModels(); + } + + /** + * Get any error from loading models.json (undefined if no error). + */ + getError(): ConfigError | undefined { + return this.configError; + } + + private loadModels() { + // Load custom models from models.json first (to know which providers to skip/override) + const { + models: customModels = [], + replacedProviders = new Set(), + overrides = new Map(), + error: configError, + } = this.loadCustomModels(); + this.configError = configError; + + const builtInModels = this.loadBuiltInModels(replacedProviders, overrides); + const combined = [...builtInModels, ...customModels]; + + // Update github-copilot base URL based on OAuth credentials + const copilotCred = this.authStorage.getOAuthCredential("github-copilot"); + if (copilotCred) { + const domain = copilotCred.enterpriseUrl + ? (normalizeDomain(copilotCred.enterpriseUrl) ?? undefined) + : undefined; + const baseUrl = getGitHubCopilotBaseUrl(copilotCred.access, domain); + this.models = combined.map(m => (m.provider === "github-copilot" ? { ...m, baseUrl } : m)); + } else { + this.models = combined; + } + } + + /** Load built-in models, skipping replaced providers and applying overrides */ + private loadBuiltInModels(replacedProviders: Set, overrides: Map): Model[] { + return getProviders() + .filter(provider => !replacedProviders.has(provider)) + .flatMap(provider => { + const models = getModels(provider as any) as Model[]; + const override = overrides.get(provider); + if (!override) return models; + + // Apply baseUrl/headers override to all models of this provider + return models.map(m => ({ + ...m, + baseUrl: override.baseUrl ?? m.baseUrl, + headers: override.headers ? { ...m.headers, ...override.headers } : m.headers, + })); + }); + } + + private loadCustomModels(): CustomModelsResult { + const { value, error, status } = this.modelsConfigFile.tryLoad(); + + if (status === "error") { + return { models: [], replacedProviders: new Set(), overrides: new Map(), error, found: true }; + } else if (status === "not-found") { + return { models: [], replacedProviders: new Set(), overrides: new Map(), found: false }; + } + + // Separate providers into "full replacement" (has models) vs "override-only" (no models) + const replacedProviders = new Set(); + const overrides = new Map(); + + for (const [providerName, providerConfig] of Object.entries(value.providers)) { + if (providerConfig.models && providerConfig.models.length > 0) { + // Has custom models -> full replacement + replacedProviders.add(providerName); + } else { + // No models -> just override baseUrl/headers on built-in + overrides.set(providerName, { + baseUrl: providerConfig.baseUrl, + headers: providerConfig.headers, + apiKey: providerConfig.apiKey, + }); + // Store API key for fallback resolver + if (providerConfig.apiKey) { + this.customProviderApiKeys.set(providerName, providerConfig.apiKey); + } + } + } + + return { models: this.parseModels(value), replacedProviders, overrides, found: true }; } private parseModels(config: ModelsConfig): Model[] { diff --git a/packages/coding-agent/src/exa/mcp-client.ts b/packages/coding-agent/src/exa/mcp-client.ts index 7fda1884f..7442fa1bf 100644 --- a/packages/coding-agent/src/exa/mcp-client.ts +++ b/packages/coding-agent/src/exa/mcp-client.ts @@ -1,10 +1,4 @@ -/** - * Exa MCP Client - * - * Client for interacting with Exa MCP servers. - */ -import * as os from "node:os"; -import { $env, isEnoent, logger } from "@oh-my-pi/pi-utils"; +import { $env, logger } from "@oh-my-pi/pi-utils"; import type { TSchema } from "@sinclair/typebox"; import type { CustomTool, CustomToolResult } from "../extensibility/custom-tools/types"; import { callMCP } from "../mcp/json-rpc"; @@ -19,32 +13,8 @@ import type { } from "./types"; /** Find EXA_API_KEY from Bun.env or .env files */ -export async function findApiKey(): Promise { - // Check Bun.env first - if ($env.EXA_API_KEY) { - return $env.EXA_API_KEY; - } - - // Try loading from .env files in cwd and home - const cwd = process.cwd(); - const home = os.homedir(); - - for (const dir of [cwd, home]) { - const envPath = `${dir}/.env`; - try { - const content = await Bun.file(envPath).text(); - const match = content.match(/^EXA_API_KEY=(.+)$/m); - if (match?.[1]) { - return match[1].trim().replace(/^["']|["']$/g, ""); - } - } catch (err) { - if (!isEnoent(err)) { - logger.debug("Error reading .env file", { path: envPath, error: String(err) }); - } - } - } - - return null; +export function findApiKey(): string | null { + return $env.EXA_API_KEY; } /** Fetch available tools from Exa MCP */ diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 24ed7ccad..089344f63 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -167,7 +167,6 @@ export { discoverCustomTSCommands, discoverExtensions, discoverMCPServers, - discoverModels, discoverPromptTemplates, discoverSkills, EditTool, diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 300610cf8..48096ae48 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -12,12 +12,12 @@ import { run } from "@oclif/core"; import { type ImageContent, supportsXhigh } from "@oh-my-pi/pi-ai"; import { $env, postmortem } from "@oh-my-pi/pi-utils"; import chalk from "chalk"; -import { type Args } from "./cli/args"; +import type { Args } from "./cli/args"; import { processFileArguments } from "./cli/file-processor"; import { listModels } from "./cli/list-models"; import { selectSession } from "./cli/session-picker"; -import { findConfigFile, getModelsPath, VERSION } from "./config"; -import type { ModelRegistry } from "./config/model-registry"; +import { findConfigFile, VERSION } from "./config"; +import { ModelRegistry, ModelsConfigFile } from "./config/model-registry"; import { parseModelPattern, parseModelString, resolveModelScope, type ScopedModel } from "./config/model-resolver"; import { Settings, settings } from "./config/settings"; import { initializeWithSettings } from "./discovery"; @@ -26,7 +26,7 @@ import type { ExtensionUIContext } from "./extensibility/extensions/types"; import { runMigrations, showDeprecationWarnings } from "./migrations"; import { InteractiveMode, runPrintMode, runRpcMode } from "./modes"; import { initTheme, stopThemeWatcher } from "./modes/theme/theme"; -import { type CreateAgentSessionOptions, createAgentSession, discoverAuthStorage, discoverModels } from "./sdk"; +import { type CreateAgentSessionOptions, createAgentSession, discoverAuthStorage } from "./sdk"; import type { AgentSession } from "./session/agent-session"; import { type SessionInfo, SessionManager } from "./session/session-manager"; import { resolvePromptInput } from "./system-prompt"; @@ -73,13 +73,16 @@ async function readPipedInput(): Promise { } } +export interface InteractiveModeNotify { + kind: "warn" | "error" | "info"; + message: string; +} + async function runInteractiveMode( session: AgentSession, version: string, changelogMarkdown: string | undefined, - modelFallbackMessage: string | undefined, - modelsJsonError: string | undefined, - migratedProviders: string[], + notifs: (InteractiveModeNotify | null)[], versionCheckPromise: Promise, initialMessages: string[], setExtensionUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void, @@ -102,16 +105,17 @@ async function runInteractiveMode( mode.renderInitialMessages(); - if (migratedProviders.length > 0) { - mode.showWarning(`Migrated credentials to agent.db: ${migratedProviders.join(", ")}`); - } - - if (modelsJsonError) { - mode.showError(`models.json error: ${modelsJsonError}`); - } - - if (modelFallbackMessage) { - mode.showWarning(modelFallbackMessage); + for (const notify of notifs) { + if (!notify) { + continue; + } + if (notify.kind === "warn") { + mode.showWarning(notify.message); + } else if (notify.kind === "error") { + mode.showError(notify.message); + } else if (notify.kind === "info") { + mode.showStatus(notify.message); + } } if (initialMessage) { @@ -482,13 +486,18 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise 0) { + notifs.push({ kind: "warn", message: `Migrated credentials to agent.db: ${migratedProviders.join(", ")}` }); + } // Create AuthStorage and ModelRegistry upfront const authStorage = await discoverAuthStorage(); - const modelRegistry = discoverModels(authStorage); + const modelRegistry = new ModelRegistry(authStorage); debugStartup("main:discoverModels"); time("discoverModels"); @@ -613,6 +622,15 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise void; private onCancelCallback: () => void; - private errorMessage?: string; + private errorMessage?: unknown; private tui: TUI; private scopedModels: ReadonlyArray; private temporaryOnly: boolean; @@ -392,7 +392,7 @@ export class ModelSelectorComponent extends Container { // Show error message or "no results" if empty if (this.errorMessage) { - const errorLines = this.errorMessage.split("\n"); + const errorLines = String(this.errorMessage).split("\n"); for (const line of errorLines) { this.listContainer.addChild(new Text(theme.fg("error", line), 0, 0)); } diff --git a/packages/coding-agent/src/modes/controllers/selector-controller.ts b/packages/coding-agent/src/modes/controllers/selector-controller.ts index 0114bdefd..ddefeea19 100644 --- a/packages/coding-agent/src/modes/controllers/selector-controller.ts +++ b/packages/coding-agent/src/modes/controllers/selector-controller.ts @@ -27,7 +27,7 @@ import { } from "../../modes/theme/theme"; import type { InteractiveModeContext } from "../../modes/types"; import { SessionManager } from "../../session/session-manager"; -import { setPreferredImageProvider, setPreferredWebSearchProvider } from "../../tools"; +import { setPreferredImageProvider, setPreferredSearchProvider } from "../../tools"; export class SelectorController { constructor(private ctx: InteractiveModeContext) {} @@ -258,7 +258,7 @@ export class SelectorController { // Provider settings - update runtime preferences case "webSearchProvider": - setPreferredWebSearchProvider(value as "auto" | "exa" | "perplexity" | "anthropic"); + setPreferredSearchProvider(value as "auto" | "exa" | "perplexity" | "anthropic"); break; case "imageProvider": setPreferredImageProvider(value as "auto" | "gemini" | "openrouter"); diff --git a/packages/coding-agent/src/prompts/system/web-search.md b/packages/coding-agent/src/prompts/system/web-search.md index 78143d16c..4251caa9c 100644 --- a/packages/coding-agent/src/prompts/system/web-search.md +++ b/packages/coding-agent/src/prompts/system/web-search.md @@ -1,4 +1,4 @@ -Research assistant with web search capabilities. Find accurate, well-sourced information; synthesize into clear answers. +Research assistant with web search capabilities. Find accurate, well-sourced information; synthesize into comprehensive, detailed answers. 1. Accuracy over speed — verify claims across multiple sources when possible @@ -14,13 +14,15 @@ Answering: - Sources conflict: acknowledge discrepancy, note which seems more authoritative - Technical topics: prefer official documentation and specifications - News/events: prefer primary reporting over aggregators +- Include concrete data: version numbers, dates, exact figures, code snippets, and specific examples -- Concise — omit filler phrases and unnecessary hedging +- Be thorough — cover the topic in depth with specific evidence, not surface-level summaries +- Omit filler phrases and unnecessary hedging, but do not sacrifice detail for brevity - Include publication dates when recency affects relevance -- Structure complex answers with clear sections +- Structure answers with clear sections when covering multiple aspects - Cite sources inline using provided search results -Answer thoroughly. Get facts right. \ No newline at end of file +Answer thoroughly and in detail. Get facts right. \ No newline at end of file diff --git a/packages/coding-agent/src/sdk.ts b/packages/coding-agent/src/sdk.ts index 4d7ac5a96..824e1e690 100644 --- a/packages/coding-agent/src/sdk.ts +++ b/packages/coding-agent/src/sdk.ts @@ -1,37 +1,8 @@ -/** - * 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(); - * - * // With custom extensions - * const session = await createAgentSession({ - * extensions: [myExtensionFactory], - * }); - * - * // Full control - * const session = await createAgentSession({ - * model: myModel, - * getApiKey: async () => Bun.env.MY_KEY, - * toolNames: ["read", "bash", "edit", "write"], // Filter tools - * extensions: [], - * skills: [], - * sessionFile: false, - * }); - * ``` - */ -import * as fs from "node:fs"; import * as path from "node:path"; import { Agent, type AgentEvent, type AgentMessage, type AgentTool, type ThinkingLevel } from "@oh-my-pi/pi-agent-core"; import { type Message, type Model, supportsXhigh } from "@oh-my-pi/pi-ai"; import type { Component } from "@oh-my-pi/pi-tui"; import { $env, logger, postmortem } from "@oh-my-pi/pi-utils"; -import { YAML } from "bun"; import chalk from "chalk"; import { loadCapability } from "./capability"; import { type Rule, ruleCapability } from "./capability/rule"; @@ -94,12 +65,12 @@ import { EditTool, FindTool, GrepTool, - getWebSearchTools, + getSearchTools, loadSshTool, PythonTool, ReadTool, setPreferredImageProvider, - setPreferredWebSearchProvider, + setPreferredSearchProvider, type Tool, type ToolSession, WriteTool, @@ -278,63 +249,6 @@ export async function discoverAuthStorage(agentDir: string = getDefaultAgentDir( return storage; } -/** - * Create a ModelRegistry with fallback support. - * Prefers models.yml over models.json. Reads from primary path first, - * then falls back to legacy paths (.pi, .claude). - */ -export function discoverModels(authStorage: AuthStorage, agentDir: string = getDefaultAgentDir()): ModelRegistry { - const yamlPath = path.join(agentDir, "models.yml"); - const jsonPath = path.join(agentDir, "models.json"); - - // Check existence of yaml and json files - let yamlExists = fs.existsSync(yamlPath); - let jsonExists = fs.existsSync(jsonPath); - - // Migrate models.json to models.yml if yaml doesn't exist but json does - if (!yamlExists && jsonExists) { - migrateModelsJsonToYaml(jsonPath, yamlPath); - yamlExists = fs.existsSync(yamlPath); - jsonExists = fs.existsSync(jsonPath); - } - - // Prefer models.yml, fall back to models.json - const primaryPath = yamlExists ? yamlPath : jsonPath; - - // Get all models config paths (user-level only), excluding the primary - const yamlPaths = getConfigDirPaths("models.yml", { project: false }); - const jsonPaths = getConfigDirPaths("models.json", { project: false }); - const allPaths = [...yamlPaths, ...jsonPaths]; - const existenceResults = allPaths.map(p => { - return { p, exists: fs.existsSync(p) }; - }); - const fallbackPaths = existenceResults.filter(({ p, exists }) => p !== primaryPath && exists).map(({ p }) => p); - - logger.debug("discoverModels", { primaryPath, fallbackPaths }); - return new ModelRegistry(authStorage, primaryPath, fallbackPaths); -} - -/** - * Migrate models.json to models.yml. - * Creates models.yml from models.json and renames the json file to .bak. - */ -function migrateModelsJsonToYaml(jsonPath: string, yamlPath: string): void { - try { - const content = fs.readFileSync(jsonPath, "utf-8"); - const parsed = JSON.parse(content); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - logger.warn("migrateModelsJsonToYaml: invalid models.json structure", { path: jsonPath }); - return; - } - fs.mkdirSync(path.dirname(yamlPath), { recursive: true }); - fs.writeFileSync(yamlPath, YAML.stringify(parsed, null, 2)); - fs.renameSync(jsonPath, `${jsonPath}.bak`); - logger.debug("migrateModelsJsonToYaml: migrated models.json to models.yml", { from: jsonPath, to: yamlPath }); - } catch (error) { - logger.warn("migrateModelsJsonToYaml: migration failed", { error: String(error) }); - } -} - /** * Discover extensions from cwd. */ @@ -585,7 +499,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // Use provided or create AuthStorage and ModelRegistry const authStorage = options.authStorage ?? (await discoverAuthStorage(agentDir)); - const modelRegistry = options.modelRegistry ?? discoverModels(authStorage, agentDir); + const modelRegistry = options.modelRegistry ?? new ModelRegistry(authStorage); time("discoverModels"); const settingsInstance = options.settingsInstance ?? (await Settings.init({ cwd, agentDir })); @@ -594,7 +508,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} time("initializeWithSettings"); // Initialize provider preferences from settings - setPreferredWebSearchProvider(settingsInstance.get("providers.webSearch") ?? "auto"); + setPreferredSearchProvider(settingsInstance.get("providers.webSearch") ?? "auto"); setPreferredImageProvider(settingsInstance.get("providers.image") ?? "auto"); const sessionManager = options.sessionManager ?? SessionManager.create(cwd); @@ -839,16 +753,16 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} // Add specialized Exa web search tools if EXA_API_KEY is available const exaSettings = settingsInstance.getGroup("exa"); if (exaSettings.enabled && exaSettings.enableSearch) { - const exaWebSearchTools = await getWebSearchTools({ + const exaSearchTools = await getSearchTools({ enableLinkedin: exaSettings.enableLinkedin as boolean, enableCompany: exaSettings.enableCompany as boolean, }); // Filter out the base web_search (already in built-in tools), add specialized Exa tools - const specializedTools = exaWebSearchTools.filter(t => t.name !== "web_search"); + const specializedTools = exaSearchTools.filter(t => t.name !== "web_search"); if (specializedTools.length > 0) { customTools.push(...specializedTools); } - time("getWebSearchTools"); + time("getSearchTools"); } const inlineExtensions: ExtensionFactory[] = options.extensions ? [...options.extensions] : []; diff --git a/packages/coding-agent/src/task/discovery.ts b/packages/coding-agent/src/task/discovery.ts index 86e67056f..b5463c66c 100644 --- a/packages/coding-agent/src/task/discovery.ts +++ b/packages/coding-agent/src/task/discovery.ts @@ -66,7 +66,7 @@ export async function discoverAgents(cwd: string): Promise { })); // Get project directories by walking up from cwd (priority order) - const projectDirs = (await findAllNearestProjectConfigDirs("agents", resolvedCwd)) + const projectDirs = findAllNearestProjectConfigDirs("agents", resolvedCwd) .filter(entry => agentSources.includes(entry.source)) .map(entry => ({ ...entry, diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 7220ac2f1..80817c365 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -9,7 +9,7 @@ import type { Api, Model, ToolChoice } from "@oh-my-pi/pi-ai"; import { logger, untilAborted } from "@oh-my-pi/pi-utils"; import type { TSchema } from "@sinclair/typebox"; import Ajv, { type ValidateFunction } from "ajv"; -import type { ModelRegistry } from "../config/model-registry"; +import { ModelRegistry } from "../config/model-registry"; import { resolveModelOverride } from "../config/model-resolver"; import { type PromptTemplate, renderPromptTemplate } from "../config/prompt-templates"; import { Settings } from "../config/settings"; @@ -19,7 +19,7 @@ import { callTool } from "../mcp/client"; import type { MCPManager } from "../mcp/manager"; import submitReminderTemplate from "../prompts/system/subagent-submit-reminder.md" with { type: "text" }; import subagentSystemPromptTemplate from "../prompts/system/subagent-system-prompt.md" with { type: "text" }; -import { createAgentSession, discoverAuthStorage, discoverModels } from "../sdk"; +import { createAgentSession, discoverAuthStorage } from "../sdk"; import type { AgentSession, AgentSessionEvent } from "../session/agent-session"; import type { AuthStorage } from "../session/auth-storage"; import { SessionManager } from "../session/session-manager"; @@ -844,7 +844,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise = { task: TaskTool.create, todo_write: s => new TodoWriteTool(s), fetch: s => new FetchTool(s), - web_search: s => new WebSearchTool(s), + web_search: s => new SearchTool(s), write: s => new WriteTool(s), }; diff --git a/packages/coding-agent/src/web/search/index.ts b/packages/coding-agent/src/web/search/index.ts index 4783a48bb..b75931fbf 100644 --- a/packages/coding-agent/src/web/search/index.ts +++ b/packages/coding-agent/src/web/search/index.ts @@ -24,15 +24,10 @@ import webSearchSystemPrompt from "../../prompts/system/web-search.md" with { ty import webSearchDescription from "../../prompts/tools/web-search.md" with { type: "text" }; import type { ToolSession } from "../../tools"; import { formatAge } from "../../tools/render-utils"; -import { - formatWebSearchProviderLabel, - getWebSearchProviderInfo, - WEB_SEARCH_PROVIDER_ORDER, - WEB_SEARCH_PROVIDERS, -} from "./provider-info"; -import { renderWebSearchCall, renderWebSearchResult, type WebSearchRenderDetails } from "./render"; -import type { WebSearchProvider, WebSearchResponse } from "./types"; -import { WebSearchProviderError } from "./types"; +import { getSearchProvider, resolveProviderChain, type SearchProvider } from "./provider-info"; +import { renderSearchCall, renderSearchResult, type SearchRenderDetails } from "./render"; +import type { SearchResponse } from "./types"; +import { SearchProviderError } from "./types"; /** Web search parameters schema */ export const webSearchSchema = Type.Object({ @@ -50,66 +45,35 @@ export const webSearchSchema = Type.Object({ limit: Type.Optional(Type.Number({ description: "Max results to return" })), }); -export type WebSearchParams = { +export type SearchParams = { query: string; provider?: "auto" | "exa" | "jina" | "anthropic" | "perplexity" | "gemini" | "codex"; recency?: "day" | "week" | "month" | "year"; limit?: number; + /** Maximum output tokens. Defaults to 4096. */ + max_tokens?: number; + /** Sampling temperature (0–1). Lower = more focused/factual. Defaults to 0.2. */ + temperature?: number; + /** Number of search results to retrieve. Defaults to 10. */ + num_search_results?: number; }; -/** Preferred provider set via settings (default: auto) */ -let preferredProvider: WebSearchProvider | "auto" = "auto"; - -/** Set the preferred web search provider from settings */ -export function setPreferredWebSearchProvider(provider: WebSearchProvider | "auto"): void { - preferredProvider = provider; +function formatProviderList(providers: SearchProvider[]): string { + return providers.map(provider => provider.label).join(", "); } -/** Determine which providers are configured (priority order) */ -async function getAvailableProviders(): Promise { - const providers: WebSearchProvider[] = []; - - for (const provider of WEB_SEARCH_PROVIDER_ORDER) { - const definition = getWebSearchProviderInfo(provider); - if (await definition.isAvailable()) { - providers.push(provider); - } - } - - return providers; -} - -function formatProviderList(providers: WebSearchProvider[]): string { - return providers.map(provider => formatWebSearchProviderLabel(provider)).join(", "); -} - -function formatProviderError(error: unknown, provider: WebSearchProvider): string { - if (error instanceof WebSearchProviderError) { +function formatProviderError(error: unknown, provider: SearchProvider): string { + if (error instanceof SearchProviderError) { if (error.provider === "anthropic" && error.status === 404) { return "Anthropic web search returned 404 (model or endpoint not found)."; } if (error.status === 401 || error.status === 403) { - return `${formatWebSearchProviderLabel(error.provider)} authorization failed (${error.status}). Check API key or base URL.`; + return `${getSearchProvider(error.provider).label} authorization failed (${error.status}). Check API key or base URL.`; } return error.message; } if (error instanceof Error) return error.message; - return `Unknown error from ${formatWebSearchProviderLabel(provider)}`; -} - -async function resolveProviderChain( - requestedProvider?: WebSearchProvider | "auto", -): Promise<{ providers: WebSearchProvider[]; allowFallback: boolean }> { - if (requestedProvider && requestedProvider !== "auto") { - return { providers: [requestedProvider], allowFallback: false }; - } - - if (preferredProvider !== "auto") { - return { providers: [preferredProvider], allowFallback: false }; - } - - const providers = await getAvailableProviders(); - return { providers, allowFallback: true }; + return `Unknown error from ${provider.label}`; } /** Truncate text for tool output */ @@ -123,7 +87,7 @@ function formatCount(label: string, count: number): string { } /** Format response for LLM consumption */ -function formatForLLM(response: WebSearchResponse): string { +function formatForLLM(response: SearchResponse): string { const parts: string[] = []; parts.push("## Answer"); @@ -197,18 +161,17 @@ function formatForLLM(response: WebSearchResponse): string { } /** Execute web search */ -async function executeWebSearch( +async function executeSearch( _toolCallId: string, - params: WebSearchParams, -): Promise<{ content: Array<{ type: "text"; text: string }>; details: WebSearchRenderDetails }> { - const { providers, allowFallback } = await resolveProviderChain(params.provider); + params: SearchParams, +): Promise<{ content: Array<{ type: "text"; text: string }>; details: SearchRenderDetails }> { + const providers = await resolveProviderChain(params.provider); if (providers.length === 0) { const message = "No web search provider configured."; - const fallbackProvider = preferredProvider === "auto" ? "anthropic" : preferredProvider; return { content: [{ type: "text" as const, text: `Error: ${message}` }], - details: { response: { provider: fallbackProvider, sources: [] }, error: message }, + details: { response: { provider: "none", sources: [] }, error: message }, }; } @@ -217,14 +180,8 @@ async function executeWebSearch( for (const provider of providers) { lastProvider = provider; - const providerDefinition = WEB_SEARCH_PROVIDERS[provider]; - if (!providerDefinition) { - lastError = new Error(`Unknown web search provider: ${provider}`); - if (!allowFallback) break; - continue; - } try { - const response = await providerDefinition.search({ + const response = await provider.search({ query: params.query.replace(/202\d/g, String(new Date().getFullYear())), // LUL limit: params.limit, recency: params.recency, @@ -239,29 +196,28 @@ async function executeWebSearch( }; } catch (error) { lastError = error; - if (!allowFallback) break; } } const baseMessage = formatProviderError(lastError, lastProvider); const message = - allowFallback && providers.length > 1 + providers.length > 1 ? `All web search providers failed (${formatProviderList(providers)}). Last error: ${baseMessage}` : baseMessage; return { content: [{ type: "text" as const, text: `Error: ${message}` }], - details: { response: { provider: lastProvider, sources: [] }, error: message }, + details: { response: { provider: lastProvider.id, sources: [] }, error: message }, }; } /** * Execute a web search query for CLI/testing workflows. */ -export async function runWebSearchQuery( - params: WebSearchParams, -): Promise<{ content: Array<{ type: "text"; text: string }>; details: WebSearchRenderDetails }> { - return executeWebSearch("cli-web-search", params); +export async function runSearchQuery( + params: SearchParams, +): Promise<{ content: Array<{ type: "text"; text: string }>; details: SearchRenderDetails }> { + return executeSearch("cli-web-search", params); } /** @@ -270,7 +226,7 @@ export async function runWebSearchQuery( * Supports Anthropic, Perplexity, Exa, Jina, Gemini, and Codex providers with automatic fallback. * Session is accepted for interface consistency but not used. */ -export class WebSearchTool implements AgentTool { +export class SearchTool implements AgentTool { public readonly name = "web_search"; public readonly label = "Web Search"; public readonly description: string; @@ -282,38 +238,32 @@ export class WebSearchTool implements AgentTool, + _onUpdate?: AgentToolUpdateCallback, _context?: AgentToolContext, - ): Promise> { - return executeWebSearch(_toolCallId, params); + ): Promise> { + return executeSearch(_toolCallId, params); } } /** Web search tool as CustomTool (for TUI rendering support) */ -export const webSearchCustomTool: CustomTool = { +export const webSearchCustomTool: CustomTool = { name: "web_search", label: "Web Search", description: renderPromptTemplate(webSearchDescription), parameters: webSearchSchema, - async execute( - toolCallId: string, - params: WebSearchParams, - _onUpdate, - _ctx: CustomToolContext, - _signal?: AbortSignal, - ) { - return executeWebSearch(toolCallId, params); + async execute(toolCallId: string, params: SearchParams, _onUpdate, _ctx: CustomToolContext, _signal?: AbortSignal) { + return executeSearch(toolCallId, params); }, - renderCall(args: WebSearchParams, theme: Theme) { - return renderWebSearchCall(args, theme); + renderCall(args: SearchParams, theme: Theme) { + return renderSearchCall(args, theme); }, renderResult(result, options: RenderResultOptions, theme: Theme) { - return renderWebSearchResult(result, options, theme); + return renderSearchResult(result, options, theme); }, }; @@ -559,19 +509,19 @@ Parameters: }; /** All Exa-specific web search tools */ -export const exaWebSearchTools: CustomTool[] = [ +export const exaSearchTools: CustomTool[] = [ webSearchDeepTool, webSearchCodeContextTool, webSearchCrawlTool, ]; /** LinkedIn-specific tool (requires LinkedIn addon on Exa account) */ -export const linkedinWebSearchTools: CustomTool[] = [webSearchLinkedinTool]; +export const linkedinSearchTools: CustomTool[] = [webSearchLinkedinTool]; /** Company-specific tool (requires Company addon on Exa account) */ -export const companyWebSearchTools: CustomTool[] = [webSearchCompanyTool]; +export const companySearchTools: CustomTool[] = [webSearchCompanyTool]; -export interface WebSearchToolsOptions { +export interface SearchToolsOptions { /** Enable LinkedIn search tool (requires Exa LinkedIn addon) */ enableLinkedin?: boolean; /** Enable company research tool (requires Exa Company addon) */ @@ -587,19 +537,19 @@ export interface WebSearchToolsOptions { * - With EXA_API_KEY + options.enableLinkedin: web_search_linkedin * - With EXA_API_KEY + options.enableCompany: web_search_company */ -export async function getWebSearchTools(options: WebSearchToolsOptions = {}): Promise[]> { +export async function getSearchTools(options: SearchToolsOptions = {}): Promise[]> { const tools: CustomTool[] = [webSearchCustomTool]; // Check for Exa API key const exaKey = await findExaKey(); if (exaKey) { - tools.push(...exaWebSearchTools); + tools.push(...exaSearchTools); if (options.enableLinkedin) { - tools.push(...linkedinWebSearchTools); + tools.push(...linkedinSearchTools); } if (options.enableCompany) { - tools.push(...companyWebSearchTools); + tools.push(...companySearchTools); } } @@ -609,15 +559,15 @@ export async function getWebSearchTools(options: WebSearchToolsOptions = {}): Pr /** * Check if Exa-specific web search tools are available. */ -export async function hasExaWebSearch(): Promise { +export async function hasExaSearch(): Promise { const exaKey = await findExaKey(); return exaKey !== null; } export { - formatWebSearchProviderLabel, - getWebSearchProviderInfo, - WEB_SEARCH_PROVIDER_ORDER, - WEB_SEARCH_PROVIDERS, + getSearchProvider, + SEARCH_PROVIDER_ORDER, + SEARCH_PROVIDERS, + setPreferredSearchProvider, } from "./provider-info"; -export type { WebSearchProvider, WebSearchResponse } from "./types"; +export type { SearchProviderId as SearchProvider, SearchResponse } from "./types"; diff --git a/packages/coding-agent/src/web/search/provider-info.ts b/packages/coding-agent/src/web/search/provider-info.ts index 3e81abbad..0442460df 100644 --- a/packages/coding-agent/src/web/search/provider-info.ts +++ b/packages/coding-agent/src/web/search/provider-info.ts @@ -1,124 +1,186 @@ +import { findApiKey as findExaKey } from "../../exa/mcp-client"; import { findAnthropicAuth } from "./auth"; import { searchAnthropic } from "./providers/anthropic"; -import { searchCodex, hasCodexWebSearch } from "./providers/codex"; +import { hasCodexSearch, searchCodex } from "./providers/codex"; import { searchExa } from "./providers/exa"; -import { searchGemini, hasGeminiWebSearch } from "./providers/gemini"; +import { findGeminiAuth, searchGemini } from "./providers/gemini"; import { findApiKey as findJinaKey, searchJina } from "./providers/jina"; import { findApiKey as findPerplexityKey, searchPerplexity } from "./providers/perplexity"; -import type { WebSearchProvider, WebSearchResponse } from "./types"; -import { findApiKey as findExaKey } from "../../exa/mcp-client"; +import type { SearchProviderId, SearchResponse } from "./types"; -export interface WebSearchProviderInfo { - id: WebSearchProvider; - label: string; - /** Authentication requirement summary. */ - auth: string; - /** Query hint for UI or docs. */ +export interface SearchParams { query: string; - isAvailable: () => Promise; - search: (params: { - query: string; - limit?: number; - recency?: "day" | "week" | "month" | "year"; - systemPrompt: string; - signal?: AbortSignal; - }) => Promise; + limit?: number; + recency?: "day" | "week" | "month" | "year"; + systemPrompt: string; + signal?: AbortSignal; + maxOutputTokens?: number; + numSearchResults?: number; + temperature?: number; } -export const WEB_SEARCH_PROVIDER_ORDER: WebSearchProvider[] = [ - "exa", - "jina", - "perplexity", - "anthropic", - "gemini", - "codex", -]; +export abstract class SearchProvider { + abstract readonly id: SearchProviderId; + abstract readonly label: string; -export const WEB_SEARCH_PROVIDERS: Record = { - exa: { - id: "exa", - label: "Exa", - auth: "EXA_API_KEY", - query: "Search query", - isAvailable: async () => Boolean(findExaKey()), - search: async ({ query, limit }) => - searchExa({ - query, - num_results: limit, - }), - }, - jina: { - id: "jina", - label: "Jina", - auth: "JINA_API_KEY", - query: "Search query", - isAvailable: async () => Boolean(findJinaKey()), - search: async ({ query, limit }) => - searchJina({ - query, - num_results: limit, - }), - }, - perplexity: { - id: "perplexity", - label: "Perplexity", - auth: "PERPLEXITY_API_KEY / PPLX_API_KEY", - query: "Search query", - isAvailable: async () => Boolean(findPerplexityKey()), - search: async ({ query, limit, recency, systemPrompt }) => - searchPerplexity({ - query, - system_prompt: systemPrompt, - search_recency_filter: recency, - num_results: limit, - }), - }, - anthropic: { - id: "anthropic", - label: "Anthropic", - auth: "ANTHROPIC_SEARCH_API_KEY / OAuth", - query: "Search query", - isAvailable: async () => Boolean(await findAnthropicAuth()), - search: async ({ query, limit, systemPrompt }) => - searchAnthropic({ - query, - system_prompt: systemPrompt, - num_results: limit, - }), - }, - gemini: { - id: "gemini", - label: "Gemini", - auth: "OAuth (agent.db: google-antigravity/google-gemini-cli)", - query: "Search query", - isAvailable: hasGeminiWebSearch, - search: async ({ query, limit, systemPrompt }) => - searchGemini({ - query, - system_prompt: systemPrompt, - num_results: limit, - }), - }, - codex: { - id: "codex", - label: "Codex", - auth: "OAuth (agent.db: openai-codex)", - query: "Search query", - isAvailable: hasCodexWebSearch, - search: async ({ query, limit, systemPrompt, signal }) => - searchCodex({ - signal, - query, - system_prompt: systemPrompt, - num_results: limit, - }), - }, -}; - -export function formatWebSearchProviderLabel(provider: WebSearchProvider): string { - return WEB_SEARCH_PROVIDERS[provider]?.label ?? "Unknown"; + abstract isAvailable(): Promise | boolean; + abstract search(params: SearchParams): Promise; } -export function getWebSearchProviderInfo(provider: WebSearchProvider): WebSearchProviderInfo { - return WEB_SEARCH_PROVIDERS[provider]; +export class ExaProvider extends SearchProvider { + readonly id: SearchProviderId = "exa"; + readonly label = "Exa"; + + isAvailable(): boolean { + try { + return !!findExaKey(); + } catch { + return false; + } + } + + search(params: SearchParams): Promise { + return searchExa(params); + } +} + +export class JinaProvider extends SearchProvider { + readonly id: SearchProviderId = "jina"; + readonly label = "Jina"; + + isAvailable() { + try { + return !!findJinaKey(); + } catch { + return false; + } + } + + search(params: SearchParams): Promise { + return searchJina(params); + } +} + +export class PerplexityProvider extends SearchProvider { + readonly id: SearchProviderId = "perplexity"; + readonly label = "Perplexity"; + + isAvailable() { + try { + return !!findPerplexityKey(); + } catch { + return false; + } + } + + search(params: SearchParams): Promise { + return searchPerplexity({ + query: params.query, + temperature: params.temperature, + max_tokens: params.maxOutputTokens, + num_search_results: params.numSearchResults, + system_prompt: params.systemPrompt, + search_recency_filter: params.recency, + num_results: params.limit, + }); + } +} + +export class AnthropicProvider extends SearchProvider { + readonly id: SearchProviderId = "anthropic"; + readonly label = "Anthropic"; + + isAvailable() { + return findAnthropicAuth().then(Boolean); + } + + search(params: SearchParams): Promise { + return searchAnthropic({ + query: params.query, + system_prompt: params.systemPrompt, + num_results: params.limit, + }); + } +} + +export class GeminiProvider extends SearchProvider { + readonly id: SearchProviderId = "gemini"; + readonly label = "Gemini"; + + isAvailable() { + return findGeminiAuth().then(Boolean); + } + + search(params: SearchParams): Promise { + return searchGemini({ + query: params.query, + system_prompt: params.systemPrompt, + num_results: params.limit, + }); + } +} + +export class CodexProvider extends SearchProvider { + readonly id: SearchProviderId = "codex"; + readonly label = "Codex"; + + isAvailable(): Promise { + return Promise.resolve(hasCodexSearch()); + } + + search(params: SearchParams): Promise { + return searchCodex({ + signal: params.signal, + query: params.query, + system_prompt: params.systemPrompt, + num_results: params.limit, + }); + } +} + +export const SEARCH_PROVIDERS: Record = { + exa: new ExaProvider(), + jina: new JinaProvider(), + perplexity: new PerplexityProvider(), + anthropic: new AnthropicProvider(), + gemini: new GeminiProvider(), + codex: new CodexProvider(), +} as const; + +export const SEARCH_PROVIDER_ORDER: SearchProviderId[] = ["exa", "jina", "perplexity", "anthropic", "gemini", "codex"]; + +export function getSearchProvider(provider: SearchProviderId): SearchProvider { + return SEARCH_PROVIDERS[provider]; +} + +/** Preferred provider set via settings (default: auto) */ +let preferredProvId: SearchProviderId | "auto" = "auto"; + +/** Set the preferred web search provider from settings */ +export function setPreferredSearchProvider(provider: SearchProviderId | "auto"): void { + preferredProvId = provider; +} + +/** Determine which providers are configured (priority order) */ +export async function resolveProviderChain( + _preferredProvider: SearchProviderId | "auto" = preferredProvId, +): Promise { + const providers: SearchProvider[] = []; + + if (preferredProvId !== "auto") { + if (await getSearchProvider(preferredProvId).isAvailable()) { + providers.push(getSearchProvider(preferredProvId)); + } + } + + for (const id of SEARCH_PROVIDER_ORDER) { + if (id === preferredProvId) continue; + + const provider = getSearchProvider(id); + if (await provider.isAvailable()) { + providers.push(provider); + } + } + + return providers; } diff --git a/packages/coding-agent/src/web/search/providers/anthropic.ts b/packages/coding-agent/src/web/search/providers/anthropic.ts index 5dfbc839e..5284735b7 100644 --- a/packages/coding-agent/src/web/search/providers/anthropic.ts +++ b/packages/coding-agent/src/web/search/providers/anthropic.ts @@ -11,11 +11,11 @@ import type { AnthropicApiResponse, AnthropicAuthConfig, AnthropicCitation, - WebSearchCitation, - WebSearchResponse, - WebSearchSource, + SearchCitation, + SearchResponse, + SearchSource, } from "../../../web/search/types"; -import { WebSearchProviderError } from "../../../web/search/types"; +import { SearchProviderError } from "../../../web/search/types"; const DEFAULT_MODEL = "claude-haiku-4-5"; const DEFAULT_MAX_TOKENS = 4096; @@ -74,9 +74,9 @@ function buildSystemBlocks( * @param query - Search query from the user * @param systemPrompt - Optional system prompt for guiding response style * @returns Raw API response from Anthropic - * @throws {WebSearchProviderError} If the API request fails + * @throws {SearchProviderError} If the API request fails */ -async function callWebSearch( +async function callSearch( auth: AnthropicAuthConfig, model: string, query: string, @@ -111,7 +111,7 @@ async function callWebSearch( if (!response.ok) { const errorText = await response.text(); - throw new WebSearchProviderError( + throw new SearchProviderError( "anthropic", `Anthropic API error (${response.status}): ${errorText}`, response.status, @@ -158,15 +158,15 @@ function parsePageAge(pageAge: string | null | undefined): number | undefined { } /** - * Parses the Anthropic API response into a unified WebSearchResponse. + * Parses the Anthropic API response into a unified SearchResponse. * @param response - Raw API response containing content blocks * @returns Normalized response with answer, sources, citations, and usage */ -function parseResponse(response: AnthropicApiResponse): WebSearchResponse { +function parseResponse(response: AnthropicApiResponse): SearchResponse { const answerParts: string[] = []; const searchQueries: string[] = []; - const sources: WebSearchSource[] = []; - const citations: WebSearchCitation[] = []; + const sources: SearchSource[] = []; + const citations: SearchCitation[] = []; for (const block of response.content) { if ( @@ -228,7 +228,7 @@ function parseResponse(response: AnthropicApiResponse): WebSearchResponse { * @returns Search response with synthesized answer, sources, and citations * @throws {Error} If no Anthropic credentials are configured */ -export async function searchAnthropic(params: AnthropicSearchParams): Promise { +export async function searchAnthropic(params: AnthropicSearchParams): Promise { const auth = await findAnthropicAuth(); if (!auth) { throw new Error( @@ -237,7 +237,7 @@ export async function searchAnthropic(params: AnthropicSearchParams): Promise { +export async function searchCodex(params: CodexSearchParams): Promise { const auth = await findCodexAuth(); if (!auth) { throw new Error( @@ -321,7 +321,7 @@ export async function searchCodex(params: CodexSearchParams): Promise { +export async function hasCodexSearch(): Promise { const auth = await findCodexAuth(); return auth !== null; } diff --git a/packages/coding-agent/src/web/search/providers/exa.ts b/packages/coding-agent/src/web/search/providers/exa.ts index b297552e4..a9152925d 100644 --- a/packages/coding-agent/src/web/search/providers/exa.ts +++ b/packages/coding-agent/src/web/search/providers/exa.ts @@ -5,8 +5,8 @@ * Returns structured search results with optional content extraction. */ import { getEnvApiKey } from "@oh-my-pi/pi-ai"; -import type { WebSearchResponse, WebSearchSource } from "../../../web/search/types"; -import { WebSearchProviderError } from "../../../web/search/types"; +import type { SearchResponse, SearchSource } from "../../../web/search/types"; +import { SearchProviderError } from "../../../web/search/types"; const EXA_API_URL = "https://api.exa.ai/search"; @@ -79,7 +79,7 @@ async function callExaSearch(apiKey: string, params: ExaSearchParams): Promise; @@ -98,7 +98,7 @@ function dateToAgeSeconds(dateStr: string | null | undefined): number | undefine } /** Execute Exa web search */ -export async function searchExa(params: ExaSearchParams): Promise { +export async function searchExa(params: ExaSearchParams): Promise { const apiKey = getEnvApiKey("exa"); if (!apiKey) { throw new Error("EXA_API_KEY not found. Set it in environment or .env file."); @@ -106,8 +106,8 @@ export async function searchExa(params: ExaSearchParams): Promise { +export async function findGeminiAuth(): Promise { const configDirs = getConfigDirPaths("", { project: false }); const expiryBuffer = 5 * 60 * 1000; // 5 minutes const now = Date.now(); @@ -191,7 +191,7 @@ interface CloudCodeResponseChunk { * @param query - Search query from the user * @param systemPrompt - Optional system prompt * @returns Parsed response with answer, sources, and usage - * @throws {WebSearchProviderError} If the API request fails + * @throws {SearchProviderError} If the API request fails */ async function callGeminiSearch( auth: GeminiAuth, @@ -199,8 +199,8 @@ async function callGeminiSearch( systemPrompt?: string, ): Promise<{ answer: string; - sources: WebSearchSource[]; - citations: WebSearchCitation[]; + sources: SearchSource[]; + citations: SearchCitation[]; searchQueries: string[]; model: string; usage?: { inputTokens: number; outputTokens: number; totalTokens: number }; @@ -244,7 +244,7 @@ async function callGeminiSearch( if (!response.ok) { const errorText = await response.text(); - throw new WebSearchProviderError( + throw new SearchProviderError( "gemini", `Gemini Cloud Code API error (${response.status}): ${errorText}`, response.status, @@ -252,13 +252,13 @@ async function callGeminiSearch( } if (!response.body) { - throw new WebSearchProviderError("gemini", "Gemini API returned no response body", 500); + throw new SearchProviderError("gemini", "Gemini API returned no response body", 500); } // Parse SSE stream const answerParts: string[] = []; - const sources: WebSearchSource[] = []; - const citations: WebSearchCitation[] = []; + const sources: SearchSource[] = []; + const citations: SearchCitation[] = []; const searchQueries: string[] = []; const seenUrls = new Set(); let model = DEFAULT_MODEL; @@ -388,7 +388,7 @@ async function callGeminiSearch( * @returns Search response with synthesized answer, sources, and citations * @throws {Error} If no Gemini OAuth credentials are configured */ -export async function searchGemini(params: GeminiSearchParams): Promise { +export async function searchGemini(params: GeminiSearchParams): Promise { const auth = await findGeminiAuth(); if (!auth) { throw new Error( @@ -415,12 +415,3 @@ export async function searchGemini(params: GeminiSearchParams): Promise { - const auth = await findGeminiAuth(); - return auth !== null; -} diff --git a/packages/coding-agent/src/web/search/providers/jina.ts b/packages/coding-agent/src/web/search/providers/jina.ts index 2a1977c3a..94d228f47 100644 --- a/packages/coding-agent/src/web/search/providers/jina.ts +++ b/packages/coding-agent/src/web/search/providers/jina.ts @@ -6,8 +6,8 @@ */ import { getEnvApiKey } from "@oh-my-pi/pi-ai"; -import type { WebSearchResponse, WebSearchSource } from "../../../web/search/types"; -import { WebSearchProviderError } from "../../../web/search/types"; +import type { SearchResponse, SearchSource } from "../../../web/search/types"; +import { SearchProviderError } from "../../../web/search/types"; const JINA_SEARCH_URL = "https://s.jina.ai"; @@ -41,7 +41,7 @@ async function callJinaSearch(apiKey: string, query: string): Promise { +export async function searchJina(params: JinaSearchParams): Promise { const apiKey = findApiKey(); if (!apiKey) { throw new Error("JINA_API_KEY not found. Set it in environment or .env file."); } const response = await callJinaSearch(apiKey, params.query); - const sources: WebSearchSource[] = []; + const sources: SearchSource[] = []; for (const result of response) { if (!result?.url) continue; diff --git a/packages/coding-agent/src/web/search/providers/perplexity.ts b/packages/coding-agent/src/web/search/providers/perplexity.ts index 0316a4167..89e24ed84 100644 --- a/packages/coding-agent/src/web/search/providers/perplexity.ts +++ b/packages/coding-agent/src/web/search/providers/perplexity.ts @@ -10,19 +10,29 @@ import type { PerplexityMessageOutput, PerplexityRequest, PerplexityResponse, - WebSearchCitation, - WebSearchResponse, - WebSearchSource, + SearchCitation, + SearchResponse, + SearchSource, } from "../../../web/search/types"; -import { WebSearchProviderError } from "../../../web/search/types"; +import { SearchProviderError } from "../../../web/search/types"; const PERPLEXITY_API_URL = "https://api.perplexity.ai/chat/completions"; +const DEFAULT_MAX_TOKENS = 4096; +const DEFAULT_TEMPERATURE = 0.2; +const DEFAULT_NUM_SEARCH_RESULTS = 10; + export interface PerplexitySearchParams { query: string; system_prompt?: string; search_recency_filter?: "hour" | "day" | "week" | "month" | "year"; num_results?: number; + /** Maximum output tokens. Defaults to 4096. */ + max_tokens?: number; + /** Sampling temperature (0–1). Lower = more focused/factual. Defaults to 0.2. */ + temperature?: number; + /** Number of search results to retrieve. Defaults to 10. */ + num_search_results?: number; } /** Find PERPLEXITY_API_KEY from environment or .env files (also checks PPLX_API_KEY) */ @@ -43,7 +53,7 @@ async function callPerplexity(apiKey: string, request: PerplexityRequest): Promi if (!response.ok) { const errorText = await response.text(); - throw new WebSearchProviderError( + throw new SearchProviderError( "perplexity", `Perplexity API error (${response.status}): ${errorText}`, response.status, @@ -71,14 +81,14 @@ function messageContentToText(content: PerplexityMessageOutput["content"]): stri return content.map(chunk => (chunk.type === "text" ? chunk.text : "")).join(""); } -/** Parse API response into unified WebSearchResponse */ -function parseResponse(response: PerplexityResponse): WebSearchResponse { +/** Parse API response into unified SearchResponse */ +function parseResponse(response: PerplexityResponse): SearchResponse { const messageContent = response.choices[0]?.message?.content ?? null; const answer = messageContentToText(messageContent); // Build sources by matching citations to search_results - const sources: WebSearchSource[] = []; - const citations: WebSearchCitation[] = []; + const sources: SearchSource[] = []; + const citations: SearchCitation[] = []; const citationUrls = response.citations ?? []; const searchResults = response.search_results ?? []; @@ -128,21 +138,26 @@ function parseResponse(response: PerplexityResponse): WebSearchResponse { } /** Execute Perplexity web search */ -export async function searchPerplexity(params: PerplexitySearchParams): Promise { +export async function searchPerplexity(params: PerplexitySearchParams): Promise { const apiKey = findApiKey(); if (!apiKey) { throw new Error("PERPLEXITY_API_KEY not found. Set it in environment or .env file."); } + const systemPrompt = params.system_prompt; const messages: PerplexityRequest["messages"] = []; - if (params.system_prompt) { - messages.push({ role: "system", content: params.system_prompt }); + if (systemPrompt) { + messages.push({ role: "system", content: systemPrompt }); } messages.push({ role: "user", content: params.query }); const request: PerplexityRequest = { model: "sonar-pro", messages, + max_tokens: params.max_tokens ?? DEFAULT_MAX_TOKENS, + temperature: params.temperature ?? DEFAULT_TEMPERATURE, + search_mode: "web", + num_search_results: params.num_search_results ?? DEFAULT_NUM_SEARCH_RESULTS, web_search_options: { search_type: "pro", search_context_size: "high", diff --git a/packages/coding-agent/src/web/search/render.ts b/packages/coding-agent/src/web/search/render.ts index 83ed8c74d..aefc2572f 100644 --- a/packages/coding-agent/src/web/search/render.ts +++ b/packages/coding-agent/src/web/search/render.ts @@ -3,6 +3,8 @@ * * Tree-based rendering with collapsed/expanded states for web search results. */ + +import { getSearchProvider } from "@oh-my-pi/pi-coding-agent/web/search/provider-info"; import type { Component } from "@oh-my-pi/pi-tui"; import { Text, visibleWidth, wrapTextWithAnsi } from "@oh-my-pi/pi-tui"; import type { RenderResultOptions } from "../../extensibility/custom-tools/types"; @@ -20,8 +22,7 @@ import { truncateToWidth, } from "../../tools/render-utils"; import { renderOutputBlock, renderStatusLine, renderTreeList } from "../../tui"; -import { formatWebSearchProviderLabel } from "./provider-info"; -import type { WebSearchResponse } from "./types"; +import type { SearchResponse } from "./types"; const MAX_COLLAPSED_ANSWER_LINES = PREVIEW_LIMITS.COLLAPSED_LINES; const MAX_EXPANDED_ANSWER_LINES = PREVIEW_LIMITS.EXPANDED_LINES; @@ -61,14 +62,14 @@ function renderFallbackText(contentText: string, expanded: boolean, theme: Theme return new Text(text, 0, 0); } -export interface WebSearchRenderDetails { - response: WebSearchResponse; +export interface SearchRenderDetails { + response: SearchResponse; error?: string; } /** Render web search result with tree-based layout */ -export function renderWebSearchResult( - result: { content: Array<{ type: string; text?: string }>; details?: WebSearchRenderDetails }, +export function renderSearchResult( + result: { content: Array<{ type: string; text?: string }>; details?: SearchRenderDetails }, options: RenderResultOptions, theme: Theme, args?: { @@ -118,7 +119,7 @@ export function renderWebSearchResult( : getPreviewLines(contentText, answerLimit, MAX_ANSWER_LINE_LEN) : []; - const providerLabel = formatWebSearchProviderLabel(provider); + const providerLabel = provider !== "none" ? getSearchProvider(provider).label : "None"; const queryPreview = args?.query ? truncateToWidth(args.query, 80) : searchQueries[0] @@ -270,7 +271,7 @@ export function renderWebSearchResult( } /** Render web search call (query preview) */ -export function renderWebSearchCall( +export function renderSearchCall( args: { query: string; provider?: string; [key: string]: unknown }, theme: Theme, ): Component { @@ -281,7 +282,7 @@ export function renderWebSearchCall( } export const webSearchToolRenderer = { - renderCall: renderWebSearchCall, - renderResult: renderWebSearchResult, + renderCall: renderSearchCall, + renderResult: renderSearchResult, mergeCallAndResult: true, }; diff --git a/packages/coding-agent/src/web/search/types.ts b/packages/coding-agent/src/web/search/types.ts index 07714b51d..ab04c08da 100644 --- a/packages/coding-agent/src/web/search/types.ts +++ b/packages/coding-agent/src/web/search/types.ts @@ -5,10 +5,10 @@ */ /** Supported web search providers */ -export type WebSearchProvider = "exa" | "jina" | "anthropic" | "perplexity" | "gemini" | "codex"; +export type SearchProviderId = "exa" | "jina" | "anthropic" | "perplexity" | "gemini" | "codex"; /** Source returned by search (all providers) */ -export interface WebSearchSource { +export interface SearchSource { title: string; url: string; snippet?: string; @@ -20,14 +20,14 @@ export interface WebSearchSource { } /** Citation with text reference (anthropic, perplexity) */ -export interface WebSearchCitation { +export interface SearchCitation { url: string; title: string; citedText?: string; } /** Usage metrics */ -export interface WebSearchUsage { +export interface SearchUsage { inputTokens?: number; outputTokens?: number; /** Anthropic: number of web search requests made */ @@ -37,18 +37,20 @@ export interface WebSearchUsage { } /** Unified response across providers */ -export interface WebSearchResponse { - provider: WebSearchProvider; +export interface SearchResponse { + provider: SearchProviderId | "none"; /** Synthesized answer text (anthropic, perplexity) */ answer?: string; /** Search result sources */ - sources: WebSearchSource[]; + sources: SearchSource[]; /** Text citations with context */ - citations?: WebSearchCitation[]; + citations?: SearchCitation[]; /** Intermediate search queries (anthropic) */ searchQueries?: string[]; + /** Follow-up question suggestions (provider-dependent) */ + relatedQuestions?: string[]; /** Token usage metrics */ - usage?: WebSearchUsage; + usage?: SearchUsage; /** Model used */ model?: string; /** Request ID for debugging */ @@ -56,14 +58,14 @@ export interface WebSearchResponse { } /** Provider-specific error with optional HTTP status */ -export class WebSearchProviderError extends Error { +export class SearchProviderError extends Error { constructor( - public readonly provider: WebSearchProvider, + public readonly provider: SearchProviderId, message: string, public readonly status?: number, ) { super(message); - this.name = "WebSearchProviderError"; + this.name = "SearchProviderError"; } } @@ -102,7 +104,7 @@ export interface AuthJson { } /** Anthropic API response types */ -export interface AnthropicWebSearchResult { +export interface AnthropicSearchResult { type: "web_search_result"; title: string; url: string; @@ -129,7 +131,7 @@ export interface AnthropicContentBlock { /** Tool input (for type="server_tool_use") */ input?: { query: string }; /** Search results (for type="web_search_tool_result") */ - content?: AnthropicWebSearchResult[]; + content?: AnthropicSearchResult[]; } export interface AnthropicApiResponse { @@ -190,7 +192,7 @@ export type PerplexityContentChunk = | PerplexityContentPdfChunk | PerplexityContentVideoChunk; -export interface PerplexityWebSearchStepDetails { +export interface PerplexitySearchStepDetails { search_results: PerplexitySearchResult[]; search_keywords: string[]; } @@ -207,7 +209,7 @@ export interface PerplexityExecutePythonStepDetails { export interface PerplexityReasoningStepInput { thought: string; type?: string | null; - web_search?: PerplexityWebSearchStepDetails | null; + web_search?: PerplexitySearchStepDetails | null; fetch_url_content?: PerplexityFetchUrlContentStepDetails | null; execute_python?: PerplexityExecutePythonStepDetails | null; } @@ -215,7 +217,7 @@ export interface PerplexityReasoningStepInput { export interface PerplexityReasoningStepOutput { thought: string; type?: string | null; - web_search?: PerplexityWebSearchStepDetails | null; + web_search?: PerplexitySearchStepDetails | null; fetch_url_content?: PerplexityFetchUrlContentStepDetails | null; execute_python?: PerplexityExecutePythonStepDetails | null; } @@ -309,7 +311,7 @@ export interface PerplexityUserLocation { region?: string | null; } -export interface PerplexityWebSearchOptions { +export interface PerplexitySearchOptions { search_context_size?: "low" | "medium" | "high"; search_type?: "fast" | "pro" | "auto" | null; user_location?: PerplexityUserLocation | null; @@ -318,6 +320,7 @@ export interface PerplexityWebSearchOptions { export interface PerplexityRequest { max_tokens?: number | null; + temperature?: number | null; n?: number | null; model: string; stream?: boolean | null; @@ -335,7 +338,7 @@ export interface PerplexityRequest { tools?: PerplexityToolSpec[] | null; tool_choice?: "none" | "auto" | "required" | null; parallel_tool_calls?: boolean | null; - web_search_options?: PerplexityWebSearchOptions; + web_search_options?: PerplexitySearchOptions; search_mode?: "web" | "academic" | "sec" | null; return_images?: boolean | null; return_related_questions?: boolean | null;