UNPKG

pi-decider

Version:

Decision backends for pi and omp — TypeSafe Jev, OpenRouter's Decisions API, or an OpenAI-compatible chat proxy — exposed as one typed tool (noul / choice / score)

155 lines (138 loc) • 6.01 kB
/** * Harness-agnostic state resolution. * * The same extension runs under pi (`~/.pi/agent`) and under omp (`~/.omp/agent`), so the config file * has to land beside whichever harness loaded us. Resolution order: * * 1. an explicit env override (PI_CODING_AGENT_DIR / OMP_CODING_AGENT_DIR) * 2. the harness marker the process carries (AI_AGENT=pi|omp) * 3. the extension's own location when it was loaded from `<agentDir>/extensions/` * 4. the first existing default directory * 5. `~/.pi/agent` * * Everything is injectable so the logic is unit-testable without a harness. */ import { existsSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; export type HarnessName = "pi" | "omp" | "unknown"; export interface HarnessDefaults { /** Harness marker value -> agent directory, relative to the home directory. */ agentDirs: Record<string, string>; /** Env var carrying the harness marker. */ markerEnv: string; /** Env vars that override the agent directory outright. */ dirEnvs: string[]; } /** pi sets `AI_AGENT=pi`; omp identifies itself the same way. */ export const HARNESS_DEFAULTS: HarnessDefaults = { agentDirs: { pi: ".pi/agent", omp: ".omp/agent" }, markerEnv: "AI_AGENT", dirEnvs: ["PI_CODING_AGENT_DIR", "OMP_CODING_AGENT_DIR"], }; export interface ResolveAgentDirOptions { env?: NodeJS.ProcessEnv; /** Module URL of the extension entry point, used to infer an agent dir from an installed location. */ moduleUrl?: string; home?: string; exists?: (path: string) => boolean; defaults?: HarnessDefaults; } export function resolveAgentDir(options: ResolveAgentDirOptions = {}): string { const env = options.env ?? process.env; const home = options.home ?? homedir(); const exists = options.exists ?? existsSync; const defaults = options.defaults ?? HARNESS_DEFAULTS; for (const name of defaults.dirEnvs) { const value = env[name]; if (value?.trim()) return resolve(value.trim().replace(/^~(?=[\\/]|$)/, home)); } const fromLocation = agentDirFromModuleUrl(options.moduleUrl, exists); if (fromLocation !== undefined) return fromLocation; const marker = env[defaults.markerEnv]?.trim().toLowerCase(); const markedDir = marker === undefined ? undefined : defaults.agentDirs[marker]; if (markedDir !== undefined) { const candidate = join(home, markedDir); if (exists(candidate)) return candidate; } for (const relative of Object.values(defaults.agentDirs)) { const candidate = join(home, relative); if (exists(candidate)) return candidate; } return join(home, defaults.agentDirs.pi); } /** `<dir>/agent/extensions/<name>/index.ts` (or `.../extensions/<name>.ts`) -> `<dir>/agent`. */ function agentDirFromModuleUrl(moduleUrl: string | undefined, exists: (path: string) => boolean): string | undefined { if (moduleUrl === undefined || moduleUrl === "") return undefined; const path = pathFromModuleUrl(moduleUrl); if (path === undefined) return undefined; let current = dirname(path); for (let depth = 0; depth < 4; depth += 1) { if (basename(current) === "extensions") { const candidate = resolve(dirname(current)); return exists(candidate) ? candidate : undefined; } const parent = dirname(current); if (parent === current) return undefined; current = parent; } return undefined; } /** * Parse a `file:` URL without `node:url`, which rejects POSIX-style paths on Windows and vice versa. * Keeps the leading slash so POSIX paths stay absolute, and handles `C:/...` and UNC forms. */ function pathFromModuleUrl(moduleUrl: string): string | undefined { const match = /^file:\/\/(.*)$/i.exec(moduleUrl); const rest = match?.[1]; if (rest === undefined) return undefined; let decoded = rest; try { decoded = decodeURIComponent(rest); } catch { // Keep the raw text when percent-decoding fails. } if (decoded.startsWith("/") || /^[A-Za-z]:[\\/]/.test(decoded)) { // `file:///C:/...` leaves a leading slash in front of the drive letter. return /^\/[A-Za-z]:[\\/]/.test(decoded) ? decoded.slice(1) : decoded; } return `//${decoded}`; } export function detectHarness(options: ResolveAgentDirOptions = {}): HarnessName { const marker = (options.env ?? process.env)[(options.defaults ?? HARNESS_DEFAULTS).markerEnv]?.trim().toLowerCase(); if (marker === "pi" || marker === "omp") return marker; return "unknown"; } /** * Authoritative resolution: ask the harness itself when its API is importable. pi exports * `getAgentDir()`, and omp is built from the same codebase under its own package name, so whichever * harness loaded us answers correctly. Falls back to the heuristic resolution otherwise. * * The specifier cannot be a static import: it exists only inside a harness (omp rewrites it to its * own package through its pi-compat layer), while this module also loads from plain scripts and tests. */ export async function resolveAgentDirWithHarness(options: ResolveAgentDirOptions = {}): Promise<string> { const override = explicitDir(options); if (override !== undefined) return override; try { const mod: { getAgentDir?: () => string } = await import("@earendil-works/pi-coding-agent"); const dir = mod.getAgentDir?.(); if (typeof dir === "string" && dir.trim() !== "") return dir; } catch { // No harness API available: fall through to the heuristic. } return resolveAgentDir(options); } function explicitDir(options: ResolveAgentDirOptions): string | undefined { const env = options.env ?? process.env; const home = options.home ?? homedir(); for (const name of (options.defaults ?? HARNESS_DEFAULTS).dirEnvs) { const value = env[name]; if (value?.trim()) return resolve(value.trim().replace(/^~(?=[\\/]|$)/, home)); } return undefined; } function basename(path: string): string { const parts = path.split(/[\\/]+/).filter((part) => part !== ""); return parts.at(-1) ?? ""; }