import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { fileURLToPath } from "node:url"; import { classifyWithLLM as invokeClassifier, type ClassifierModel } from "./classifier.js"; import { createPipeline, type ClassificationPipeline, } from "./classification-pipeline.js"; import { cachePath, lookupCache, touchCacheEntry, loadCache, saveCache, updateCache, demoteCacheEntry, warmStartCache, DEFAULT_MAX_ENTRIES, DEFAULT_THRESHOLD, type CacheEntry, } from "./cache.js"; import { loadConfig, loadRules, validateConfig, generateTierDescriptions, type BifrostConfig, } from "./config.js"; import { diagnoseCandidates, findCandidates, getStrategy, modelKey, resolveModelWithFallback, } from "./routing.js"; import { QuotaStore } from "./quota.js"; import { ReliabilityStore } from "./reliability-store.js"; import { loadRuntimeState, runtimeStatePath, saveRuntimeState } from "./runtime-state.js"; import { createCommandRouter, getBifrostCommandCompletions, log, logOverwrite, uiBusy, uiDone, setBifrostSilent, syncBifrostModeStatus, clearBifrostWidgets, formatBifrostRouting, type BifrostState } from "./commands.js"; import { setupDebug, debug, debugMeasure } from "./debug.js"; import { parseInlineOverride } from "./inline-override.js"; import { formatDiagnostic, parseSetModelError, patternUnresolvable, classifierModelMissing, } from "./diagnostics.js"; import { RuntimeReliabilityTracker } from "./runtime-reliability.js"; import { assessThinking, clampToModel, compareThinkingLevels, ThinkingSession, type ThinkingDecision, type ThinkingLevel } from "./thinking.ts"; import { SessionRoutingContext } from "./session-context.js"; import { REGISTRY_REFRESH_TTL_MS, setBifrostStatus, setBifrostWorkingMessage, shouldRefreshRegistry, } from "./ux-status.js"; // ── Pipeline builder (composition root) ──────────────────────── function resolveClassifierModels( ctx: ExtensionContext, config: BifrostConfig, ): ClassifierModel[] { const pattern = config.classifier?.model; if (!pattern) return []; return findCandidates(ctx, pattern) .slice(0, 3) .map((model) => ({ kind: "registry" as const, model })); } function endpointClassifier(id: string, endpoint: string): ClassifierModel { return { kind: "endpoint", id, baseUrl: endpoint }; } function buildPipeline( ctx: ExtensionContext, config: BifrostConfig, cacheEntries: CacheEntry[], classifierEnabled: boolean, sessionContext: SessionRoutingContext, ): ClassificationPipeline { const tiers = Object.keys(config.models ?? {}); const cacheCfg = config.cache; const cacheEnabled = cacheCfg?.enabled ?? true; const threshold = cacheCfg?.threshold ?? DEFAULT_THRESHOLD; // Resolve classifier models once at pipeline construction. // If classifier is disabled, pass empty array — pipeline skips LLM stage. let classifierModels: ClassifierModel[] = []; if (classifierEnabled && tiers.length > 0) { const classifierEndpoint = config.classifier?.endpoint; if (classifierEndpoint) { const rawModel = config.classifier?.model; const modelId = Array.isArray(rawModel) ? rawModel[0] : (rawModel ?? "classifier"); classifierModels = [endpointClassifier(modelId, classifierEndpoint)]; } else { classifierModels = resolveClassifierModels(ctx, config); } } const rules = loadRules(process.cwd(), config); const tierDescriptions = generateTierDescriptions(rules, tiers); return createPipeline({ cacheLookup: (text) => { if (!cacheEnabled) return undefined; const entry = lookupCache(cacheEntries, text, threshold); if (entry) { touchCacheEntry(entry); return entry.category; } return undefined; }, classifierModels, classifyWithLLM: (model, text, tiers) => invokeClassifier(ctx, model, tiers, text, { systemPrompt: config.classifier?.systemPrompt, maxTokens: config.classifier?.maxTokens, temperature: config.classifier?.temperature, method: config.classifier?.method, tierDescriptions, }), regexRules: rules, defaultTier: config.default, tiers, sessionContext, complexityEnabled: true, }); } export default function bifrostExtension(pi: ExtensionAPI) { const extensionDir = fileURLToPath(new URL(".", import.meta.url)); // Setup debug logging first — so startup errors are captured. const bootConfig = loadConfig(process.cwd(), extensionDir); if (bootConfig.debug?.enabled) { setupDebug(bootConfig.debug, process.cwd()); debug("bifrost", "startup", { extensionDir }); } const config = bootConfig; const cacheFilePath = cachePath(process.cwd(), config.cache?.path); // Validate config on startup. Errors are logged; the extension // continues with best-effort routing for warnings. const configIssues = validateConfig(config); if (!config.silent) { for (const issue of configIssues) { const tag = issue.severity === "error" ? "error" : "warning"; console.error(`[bifrost/config] ${tag}: ${issue.message}`); } } const cacheEntries = loadCache(cacheFilePath); const reliabilityStore = new ReliabilityStore({ cwd: process.cwd(), config: config.reliability }); const quotaStore = new QuotaStore(config.quotaRouting); const runtimeStateFile = runtimeStatePath(process.cwd()); const runtimeState = loadRuntimeState(runtimeStateFile, { enabled: config.enabled ?? true, pinned: false, classifierEnabled: config.classifier?.enabled ?? true, thinkingMode: config.thinking?.mode ?? "off", silent: config.silent ?? false, }); let selfSelecting = false; let selfSettingThinkingLevel: ThinkingLevel | undefined; const thinkingSession = new ThinkingSession(); const runtimeReliability = new RuntimeReliabilityTracker(); const sessionContext = new SessionRoutingContext(); let lastRoutedPrompt: string | undefined; let pipeline: ClassificationPipeline | undefined; let startupValidated = false; const warnedPatterns = new Set(); function getPipeline(ctx: ExtensionContext): ClassificationPipeline { if (!pipeline) { pipeline = buildPipeline(ctx, state.config, state.cacheEntries, state.classifierEnabled, sessionContext); } return pipeline; } function invalidatePipeline() { debug("bifrost", "pipeline.invalidate"); pipeline = undefined; } function summarizeQuota(store: QuotaStore): Record { const out: Record = {}; for (const [k, v] of Object.entries(store.getSnapshot().byProvider)) { out[k] = typeof v.weeklyRemainingFraction === "number" ? (v.weeklyRemainingFraction * 100).toFixed(0) + "%" : "?"; } return out; } // Mutable state shared with command handlers. const state: BifrostState = { config, enabled: runtimeState.enabled, classifierEnabled: runtimeState.classifierEnabled, thinkingMode: runtimeState.thinkingMode ?? "off", thinkingPinned: false, thinkingLevel: "off", pinned: runtimeState.pinned, silent: runtimeState.silent, cacheEntries, reliabilityStore, extensionDir, getPipeline, invalidatePipeline, saveModeState: () => saveRuntimeState(runtimeStateFile, { enabled: state.enabled, classifierEnabled: state.classifierEnabled, thinkingMode: state.thinkingMode, silent: state.silent, }), lastRegistryRefreshAt: undefined, forceRegistryRefresh: false, }; function decideThinking( prompt: string, selectedTier: string, model: { reasoning?: boolean; thinkingLevelMap?: Record }, preview = false, ) { const thinkingConfig = state.config.thinking; const rawDecision = assessThinking({ text: prompt, turnDepth: thinkingSession.turnDepth(prompt), lastTurnFailed: thinkingSession.getLastTurnOutcome().failed, lastTurnErrored: thinkingSession.getLastTurnOutcome().errored, }); const decision: ThinkingDecision = rawDecision.defaulted ? { ...rawDecision, level: thinkingConfig?.defaultLevel ?? "medium", defaulted: true } : rawDecision; const reasons = decision.reasons.length > 0 ? [...decision.reasons] : ["configured default"]; let level = decision.level; const sticky = thinkingSession.suggest(prompt, !preview); if (sticky && compareThinkingLevels(level, sticky) < 0) { level = sticky; reasons.push(`sticky task floor ${sticky}`); } const cap = thinkingConfig?.maxLevel ?? "high"; if (compareThinkingLevels(level, cap) > 0) { level = cap; reasons.push(`maximum ${cap}`); } const tierCap = thinkingConfig?.byTier?.[selectedTier]; if (tierCap && compareThinkingLevels(level, tierCap) > 0) { level = tierCap; reasons.push(`${selectedTier} tier maximum ${tierCap}`); } const clamp = clampToModel(level, model); if (clamp.reason) reasons.push(clamp.reason); const readableReasons = reasons.map((reason) => reason.replace(/^[+-]\d+\s+/, "").replaceAll("-", " ")); return { level: clamp.level, score: decision.score, reasons, summary: `score ${decision.score}: ${readableReasons.join(", ")}`, }; } state.previewThinking = (prompt, selectedTier, model) => { if (state.thinkingPinned) { return { level: state.thinkingLevel, mode: "pinned", summary: "manual thinking level is pinned" }; } if (state.thinkingMode === "off") { return { level: state.thinkingLevel, mode: "off", summary: "automatic thinking selection is disabled" }; } const decision = decideThinking(prompt, selectedTier, model ?? {}, true); return { level: decision.level, mode: state.thinkingMode, summary: decision.summary }; }; const handleCommand = createCommandRouter(state); pi.registerCommand("bifrost", { description: "Bifrost model router control", getArgumentCompletions: getBifrostCommandCompletions, handler: async (args, ctx) => { await handleCommand(args, ctx); }, }); pi.registerShortcut("ctrl+delete", { description: "Unpin Bifrost model and thinking", handler: async (ctx) => { const wasPinned = state.pinned || state.thinkingPinned; state.pinned = false; state.thinkingPinned = false; state.saveModeState(); syncBifrostModeStatus(ctx, state); clearBifrostWidgets(ctx); log(ctx, wasPinned ? "Bifrost unpinned (model + thinking)" : "Bifrost already unpinned"); }, }); pi.on("session_start", async (_event, ctx) => { state.thinkingLevel = pi.getThinkingLevel(); setBifrostSilent(ctx, state.silent); syncBifrostModeStatus(ctx, state); clearBifrostWidgets(ctx); void quotaStore.refreshIfStale(Date.now()); // Warm-start cache if empty if (state.cacheEntries.length === 0) { const rules = loadRules(process.cwd(), state.config); const tiers = Object.keys(state.config.models ?? {}); const maxEntries = state.config.cache?.maxEntries ?? DEFAULT_MAX_ENTRIES; state.cacheEntries = warmStartCache(state.cacheEntries, rules, tiers, maxEntries); if (state.cacheEntries.length > 0) { saveCache(cacheFilePath, state.cacheEntries); invalidatePipeline(); debug("cache", "warm_start", { entries: state.cacheEntries.length }); } } if (!startupValidated && state.enabled) { startupValidated = true; for (const [tier, patterns] of Object.entries(state.config.models ?? {})) { const { unresolved } = diagnoseCandidates(ctx, patterns); for (const p of unresolved) { log(ctx, formatDiagnostic(patternUnresolvable(tier, p)), "warning"); } } const classifierPattern = state.config.classifier?.model; if (classifierPattern && state.classifierEnabled) { const { candidates } = diagnoseCandidates(ctx, classifierPattern); if (candidates.length === 0) { const patternStr = Array.isArray(classifierPattern) ? classifierPattern[0] : classifierPattern; log(ctx, formatDiagnostic(classifierModelMissing(patternStr)), "warning"); } } } }); pi.on("agent_end", async (event) => { runtimeReliability.observe(event.messages); }); pi.on("turn_end", async (event) => { const failed = event.toolResults.some((result) => result.isError === true); const errored = (event.message as { stopReason?: unknown })?.stopReason === "error"; thinkingSession.noteTurnOutcome(failed, errored); }); pi.on("agent_settled", async (_event, ctx) => { setBifrostSilent(ctx, state.silent); const settled = runtimeReliability.settle(); if (!settled || !state.enabled || state.config.reliability?.enabled === false) return; // Policy A: failure logged, clean settle silent (trial-only success). // Intentional — normal routing produces no log noise. state.reliabilityStore.recordSettled(settled.model, settled.reason); if (settled.reason) { const httpMatch = settled.reason.match(/\b([45]\d{2})\b/); const detail = httpMatch ? `HTTP ${httpMatch[1]}; ` : ""; log(ctx, `Bifrost: provider failure for ${settled.model} (${detail}circuit opened); next prompt routes to the next healthy model in its category.`, "warning"); } }); pi.on("thinking_level_select", async (event, ctx) => { if (selfSettingThinkingLevel === event.level) { selfSettingThinkingLevel = undefined; state.thinkingLevel = event.level; return; } state.thinkingLevel = event.level; state.thinkingPinned = true; thinkingSession.reset(); syncBifrostModeStatus(ctx, state); log(ctx, `Thinking level manually changed to ${event.level}; Bifrost thinking pinned.`); }); pi.on("model_select", async (_event, ctx) => { setBifrostSilent(ctx, state.silent); if (selfSelecting) { selfSelecting = false; return; } if (!state.enabled) return; if (lastRoutedPrompt) { const tiers = Object.keys(state.config.models ?? {}); const escalated = demoteCacheEntry(state.cacheEntries, lastRoutedPrompt, tiers); if (escalated) { saveCache(cacheFilePath, state.cacheEntries); invalidatePipeline(); debug("feedback", "demotion_escalated", { prompt: lastRoutedPrompt.slice(0, 50) }); } lastRoutedPrompt = undefined; } sessionContext.reset(); state.pinned = true; state.saveModeState(); debug("bifrost", "model_select", { model: modelKey(ctx.model) }); syncBifrostModeStatus(ctx, state); clearBifrostWidgets(ctx); logOverwrite( ctx, `Model manually changed to ${modelKey(ctx.model)}; Bifrost pinned.`, ); }); pi.on("input", async (event, ctx) => { setBifrostSilent(ctx, state.silent); // Safety: clear any guard left unconsumed from the previous turn so it can't wedge. // The current turn's thinking_level_select has already been delivered by now. selfSettingThinkingLevel = undefined; if (event.source === "extension") return { action: "continue" }; clearBifrostWidgets(ctx); // Passive subagent observation — logged even when routing is disabled, // so child-session model usage stays visible in debug logs. if (process.env.PI_SUBAGENT_RUN_ID) { debug("input", "subagent", { source: "PI-subagent", agent: process.env.PI_SUBAGENT_CHILD_AGENT, model: modelKey(ctx.model), thinkingLevel: ctx.thinkingLevel, depth: process.env.PI_SUBAGENT_PARENT_DEPTH, }); } if (!state.enabled || state.pinned) { debug("input", "bypass", { enabled: state.enabled, pinned: state.pinned }); syncBifrostModeStatus(ctx, state); if (state.pinned) { log(ctx, formatBifrostRouting("", modelKey(ctx.model), "", true)); } return { action: "continue" }; } const text = event.text.trim(); if (text.startsWith("/")) return { action: "continue" }; // Inline tier override: "frontier debug this" forces that tier for one prompt. // Pi reserves / for commands, ! for bash. Just type the tier name as first word. const { forcedTier, promptText } = parseInlineOverride(text, state.config.models); if (forcedTier) { debug("input", "inline_override", { tier: forcedTier }); } // Inline override should strip the tier keyword from what LLM sees. const defaultAction = forcedTier ? { action: "transform" as const, text: promptText } : { action: "continue" as const }; const endInput = debugMeasure("input", "total"); debug("input", "prompt", { length: promptText.length }); const shouldRefresh = state.classifierEnabled ? shouldRefreshRegistry(state, Date.now(), REGISTRY_REFRESH_TTL_MS) : false; try { if (shouldRefresh) { setBifrostWorkingMessage(ctx, "Bifrost checking models..."); const endRefresh = debugMeasure("input", "registry.refresh"); try { await ctx.modelRegistry.refresh(); state.lastRegistryRefreshAt = Date.now(); state.forceRegistryRefresh = false; invalidatePipeline(); endRefresh(); } catch (err) { debug("input", "registry.refresh.error", { error: String(err) }); log(ctx, `model registry refresh failed: ${String(err).slice(0, 200)}`, "warning"); } } setBifrostStatus(ctx, forcedTier ? `using ${forcedTier}...` : "classifying prompt...", "accent"); uiBusy(ctx, forcedTier ? `Bifrost using ${forcedTier}...` : "Bifrost classifying..."); setBifrostWorkingMessage(ctx, forcedTier ? `Bifrost using ${forcedTier}...` : "Bifrost classifying..."); const endClassify = debugMeasure("input", "classify"); const classification = forcedTier ? { kind: "classified" as const, tier: forcedTier, source: "inline" as const } : await getPipeline(ctx).classify(promptText); if (classification.kind === "classified") { const tag = classification.source === "inline" ? "!" : classification.source; log(ctx, `classify: ${classification.tier} [${tag}]`); sessionContext.record(classification.tier, promptText); lastRoutedPrompt = promptText; } void quotaStore.refreshIfStale(Date.now()); endClassify({ kind: classification.kind, tier: classification.kind !== "unclassified" ? classification.tier : undefined }); uiDone(ctx); setBifrostWorkingMessage(ctx, undefined); if (classification.kind === "unclassified") { log(ctx, "Bifrost: no tier matched — using default model", "warning"); debug("input", "unclassified"); syncBifrostModeStatus(ctx, state); endInput(); return defaultAction; } const tier = classification.tier; const source = classification.kind === "classified" ? classification.source : "fallback"; const pattern = state.config.models?.[tier] ?? tier; const strategy = getStrategy(state.config.categoryStrategies, state.config.strategy, tier); const defaultTier = state.config.default; const defaultPattern = defaultTier ? (state.config.models?.[defaultTier] ?? defaultTier) : undefined; const defaultStrategy = defaultTier ? getStrategy(state.config.categoryStrategies, state.config.strategy, defaultTier) : strategy; const { unresolved } = diagnoseCandidates(ctx, pattern); for (const p of unresolved) { const warnKey = `${tier}:${p}`; if (!warnedPatterns.has(warnKey)) { warnedPatterns.add(warnKey); log(ctx, formatDiagnostic(patternUnresolvable(tier, p)), "warning"); } } const resolved = resolveModelWithFallback(ctx, { requestedTier: tier, requestedPattern: pattern, requestedStrategy: strategy, defaultTier, defaultPattern, defaultStrategy, reliabilityState: state.reliabilityStore.getState(), reliabilityConfig: state.config.reliability, quota: quotaStore.getSnapshot(), quotaConfig: state.config.quotaRouting, }); const model = resolved.selected; const selectedTier = resolved.selectedTier ?? tier; // If selected model is half-open, mark trial in progress if (model) { const circuit = state.reliabilityStore.getCircuitState(modelKey(model)); if (circuit.halfOpen && !circuit.trialActive) { state.reliabilityStore.beginTrial(modelKey(model)); } } if (!model) { state.forceRegistryRefresh = true; debug("input", "no_model", { tier, fallbackReason: resolved.fallbackReason, skipped: resolved.skipped.length }); const why = resolved.fallbackReason ? ` (${resolved.fallbackReason})` : ""; log(ctx, `Bifrost: tier "${tier}" matched but no healthy model available${why}`, "warning"); syncBifrostModeStatus(ctx, state); endInput(); return defaultAction; } if ( classification.kind === "classified" && classification.source === "classifier" ) { const maxEntries = state.config.cache?.maxEntries ?? DEFAULT_MAX_ENTRIES; if (state.config.cache?.enabled ?? true) { const endCacheSave = debugMeasure("input", "cacheSave"); state.cacheEntries = updateCache(state.cacheEntries, promptText, tier, maxEntries); saveCache(cacheFilePath, state.cacheEntries); invalidatePipeline(); endCacheSave({ entries: state.cacheEntries.length }); } } const applyThinking = () => { if (state.thinkingMode === "off" || state.thinkingPinned) return; const decision = decideThinking(promptText, selectedTier, model); state.lastThinkingDecision = { score: decision.score, level: decision.level, reasons: decision.reasons }; thinkingSession.record(decision.level, promptText); if (state.thinkingMode === "apply" && decision.level !== pi.getThinkingLevel()) { selfSettingThinkingLevel = decision.level; pi.setThinkingLevel(decision.level); state.thinkingLevel = pi.getThinkingLevel(); // ponytail: do NOT clear the guard here. thinking_level_select fires async, after this // block returns; the handler consumes & clears selfSettingThinkingLevel on match. Clearing // synchronously let the event reach the handler with guard already undefined -> false // "Thinking level manually changed" log. } log(ctx, `thinking: ${state.thinkingMode} ${decision.level} (${decision.summary})`); }; if (modelKey(model) === modelKey(ctx.model)) { applyThinking(); uiDone(ctx); syncBifrostModeStatus(ctx, state); const reason = resolved.fallbackReason ? `, ${resolved.fallbackReason}` : ""; log(ctx, formatBifrostRouting(tier, modelKey(model), `already active, ${source}${reason}`)); debug("input", "model_unchanged", { model: modelKey(model), selectedTier, fallbackReason: resolved.fallbackReason, skipped: resolved.skipped.length, thinkingLevel: ctx.thinkingLevel }); debug("input", "model_selected", { model: modelKey(model), tier: selectedTier, strategy, source, fallbackReason: resolved.fallbackReason, thinkingLevel: ctx.thinkingLevel, quota: summarizeQuota(quotaStore) }); runtimeReliability.begin(modelKey(model)); endInput({ model: modelKey(model), tier: selectedTier, strategy, source, thinkingLevel: ctx.thinkingLevel }); return defaultAction; } uiBusy(ctx, `Bifrost routing to ${modelKey(model)}...`); setBifrostWorkingMessage(ctx, `Bifrost routing to ${modelKey(model)}...`); selfSelecting = true; const endSwitch = debugMeasure("input", "setModel"); let ok = false; let setModelError: unknown; try { ok = await pi.setModel(model); } catch (err) { setModelError = err; debug("input", "setModel.throw", { model: modelKey(model), error: String(err) }); } endSwitch({ model: modelKey(model), ok }); uiDone(ctx); setBifrostWorkingMessage(ctx, undefined); if (!ok) { selfSelecting = false; state.forceRegistryRefresh = true; const diagnostic = parseSetModelError(setModelError, modelKey(model)); state.reliabilityStore.recordFailure(modelKey(model), "setModel", diagnostic.message); syncBifrostModeStatus(ctx, state); log(ctx, `Bifrost: ${formatDiagnostic(diagnostic)}`, "error"); endInput({ model: modelKey(model), ok: false }); return defaultAction; } applyThinking(); const detail = [ selectedTier !== tier ? `selected tier ${selectedTier}` : undefined, resolved.fallbackReason, resolved.skipped.length > 0 ? `${resolved.skipped.length} skipped` : undefined, ].filter(Boolean).join(", "); const doneMsg = classification.kind === "classified" ? formatBifrostRouting(tier, modelKey(model), `${classification.source}${detail ? `; ${detail}` : ""}`) : formatBifrostRouting(tier, modelKey(model), `fallback${detail ? `; ${detail}` : ""}`); syncBifrostModeStatus(ctx, state); log(ctx, doneMsg); runtimeReliability.begin(modelKey(model)); debug("input", "model_selected", { model: modelKey(model), tier: selectedTier, strategy, source, fallbackReason: resolved.fallbackReason, thinkingLevel: ctx.thinkingLevel, quota: summarizeQuota(quotaStore) }); endInput({ model: modelKey(model), tier: selectedTier, strategy, source, thinkingLevel: ctx.thinkingLevel }); return defaultAction; } finally { uiDone(ctx); setBifrostWorkingMessage(ctx, undefined); syncBifrostModeStatus(ctx, state); } }); }