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
text/typescript
/**
* 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) ?? "";
}