diff --git a/bun.lock b/bun.lock index 5efc51863..3547d4da0 100644 --- a/bun.lock +++ b/bun.lock @@ -28,13 +28,13 @@ "typescript": "^5.7.3", }, "optionalDependencies": { - "oh-my-opencode-darwin-arm64": "3.1.6", - "oh-my-opencode-darwin-x64": "3.1.6", - "oh-my-opencode-linux-arm64": "3.1.6", - "oh-my-opencode-linux-arm64-musl": "3.1.6", - "oh-my-opencode-linux-x64": "3.1.6", - "oh-my-opencode-linux-x64-musl": "3.1.6", - "oh-my-opencode-windows-x64": "3.1.6", + "oh-my-opencode-darwin-arm64": "3.1.10", + "oh-my-opencode-darwin-x64": "3.1.10", + "oh-my-opencode-linux-arm64": "3.1.10", + "oh-my-opencode-linux-arm64-musl": "3.1.10", + "oh-my-opencode-linux-x64": "3.1.10", + "oh-my-opencode-linux-x64-musl": "3.1.10", + "oh-my-opencode-windows-x64": "3.1.10", }, }, }, @@ -226,19 +226,19 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.1.6", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-KK+ptnkBigvDYbRtF/B5izEC4IoXDS8mAnRHWFBSCINhzQR2No6AtEcwijd6vKBPR+/r71ofq/8mTsIeb1PEVQ=="], + "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.1.10", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-6qsZQtrtBYZLufcXTTuUUMEG9PoG9Y98pX+HFVn2xHIEc6GpwR6i5xY8McFHmqPkC388tzybD556JhKqPX7Pnw=="], - "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.1.6", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-UkPI/RUi7INarFasBUZ4Rous6RUQXsU2nr0V8KFJp+70END43D/96dDUwX+zmPtpDhD+DfWkejuwzqfkZJ2ZDQ=="], + "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.1.10", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-I1tQQbcpSBvLGXTO652mBqlyIpwYhYuIlSJmrSM33YRGBiaUuhMASnHQsms+E0eC3U/TOyqomU/4KPnbWyxs4w=="], - "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.1.6", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-gvmvgh7WtTtcHiCbG7z43DOYfY/jrf2S6TX/jBMX2/e1AGkcLKwz30NjGhZxeK5SyzxRVypgfZZK1IuriRgbdA=="], + "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.1.10", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-r6Rm5Ru/WwcBKKuPIP0RreI0gnf+MYRV0mmzPBVhMZdPWSC/eTT3GdyqFDZ4cCN76n5aea0sa5PPW7iPF+Uw6Q=="], - "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.1.6", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-j3R76pmQ4HGVGFJUMMCeF/1lO3Jg7xFdpcBUKCeFh42N1jMgn1aeyxkAaJYB9RwCF/p6+P8B6gVDLCEDu2mxjA=="], + "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.1.10", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-UVo5OWO92DPIFhoEkw0tj8IcZyUKOG6NlFs1+tSExz7qrgkr0IloxpLslGMmdc895xxpljrr/FobYktLxyJbcg=="], - "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.1.6", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-VDdo0tHCOr5nm7ajd652u798nPNOLRSTcPOnVh6vIPddkZ+ujRke+enOKOw9Pd5e+4AkthqHBwFXNm2VFgnEKg=="], + "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.1.10", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-3g99z2FweMzHSUYuzgU0E2H0kjVmtOhPZdavwVqcHQtLQ9NNhwfnIvj3yFBif+kGJphP9RDnByC1oA8Q26UrCg=="], - "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.1.6", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-hBG/dhsr8PZelUlYsPBruSLnelB9ocB7H92I+S9svTpDVo67rAmXOoR04twKQ9TeCO4ShOa6hhMhbQnuI8fgNw=="], + "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.1.10", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-2HS9Ju0Cr433lMFJtu/7bShApOJywp+zmVCduQUBWFi3xbX1nm5sJwWDhw1Wx+VcqHEuJl/SQzWPE4vaqkEQng=="], - "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.1.6", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-c8Awp03p2DsbS0G589nzveRCeJPgJRJ0vQrha4ChRmmo31Qc5OSmJ5xuMaF8L4nM+/trbTgAQMFMtCMLgtC8IQ=="], + "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.1.10", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-QLncZJSlWmmcuXrAVKIH6a9Om1Ym6pkhG4hAxaD5K5aF1jw2QFsadjoT12VNq2WzQb+Pg5Y6IWvoow0ZR0aEvw=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], diff --git a/src/agents/sisyphus.ts b/src/agents/sisyphus.ts index 37c276c8e..61b4cbc2c 100644 --- a/src/agents/sisyphus.ts +++ b/src/agents/sisyphus.ts @@ -1,8 +1,14 @@ import type { AgentConfig } from "@opencode-ai/sdk" -import type { AgentMode } from "./types" +import type { AgentMode, AgentPromptMetadata } from "./types" import { isGptModel } from "./types" const MODE: AgentMode = "primary" +export const SISYPHUS_PROMPT_METADATA: AgentPromptMetadata = { + category: "utility", + cost: "EXPENSIVE", + promptAlias: "Sisyphus", + triggers: [], +} import type { AvailableAgent, AvailableTool, AvailableSkill, AvailableCategory } from "./dynamic-agent-prompt-builder" import { buildKeyTriggersSection, diff --git a/src/agents/utils.ts b/src/agents/utils.ts index 2ce55342a..d4a80d944 100644 --- a/src/agents/utils.ts +++ b/src/agents/utils.ts @@ -6,11 +6,11 @@ import { createOracleAgent, ORACLE_PROMPT_METADATA } from "./oracle" import { createLibrarianAgent, LIBRARIAN_PROMPT_METADATA } from "./librarian" import { createExploreAgent, EXPLORE_PROMPT_METADATA } from "./explore" import { createMultimodalLookerAgent, MULTIMODAL_LOOKER_PROMPT_METADATA } from "./multimodal-looker" -import { createMetisAgent } from "./metis" -import { createAtlasAgent } from "./atlas" -import { createMomusAgent } from "./momus" +import { createMetisAgent, metisPromptMetadata } from "./metis" +import { createAtlasAgent, atlasPromptMetadata } from "./atlas" +import { createMomusAgent, momusPromptMetadata } from "./momus" import type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder" -import { deepMerge, fetchAvailableModels, resolveModelWithFallback, AGENT_MODEL_REQUIREMENTS, findCaseInsensitive, includesCaseInsensitive, readConnectedProvidersCache, isModelAvailable } from "../shared" +import { deepMerge, fetchAvailableModels, resolveModelPipeline, AGENT_MODEL_REQUIREMENTS, readConnectedProvidersCache, isModelAvailable } from "../shared" import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants" import { resolveMultipleSkills } from "../features/opencode-skill-loader/skill-content" import { createBuiltinSkills } from "../features/builtin-skills" @@ -41,6 +41,9 @@ const agentMetadata: Partial> = { librarian: LIBRARIAN_PROMPT_METADATA, explore: EXPLORE_PROMPT_METADATA, "multimodal-looker": MULTIMODAL_LOOKER_PROMPT_METADATA, + metis: metisPromptMetadata, + momus: momusPromptMetadata, + atlas: atlasPromptMetadata, } function isFactory(source: AgentSource): source is AgentFactory { @@ -147,6 +150,45 @@ function applyCategoryOverride( return result as AgentConfig } +function applyModelResolution(input: { + uiSelectedModel?: string + userModel?: string + requirement?: { fallbackChain?: { providers: string[]; model: string; variant?: string }[] } + availableModels: Set + systemDefaultModel?: string +}) { + const { uiSelectedModel, userModel, requirement, availableModels, systemDefaultModel } = input + return resolveModelPipeline({ + intent: { uiSelectedModel, userModel }, + constraints: { availableModels }, + policy: { fallbackChain: requirement?.fallbackChain, systemDefaultModel }, + }) +} + +function applyEnvironmentContext(config: AgentConfig, directory?: string): AgentConfig { + if (!directory || !config.prompt) return config + const envContext = createEnvContext() + return { ...config, prompt: config.prompt + envContext } +} + +function applyOverrides( + config: AgentConfig, + override: AgentOverrideConfig | undefined, + mergedCategories: Record +): AgentConfig { + let result = config + const overrideCategory = (override as Record | undefined)?.category as string | undefined + if (overrideCategory) { + result = applyCategoryOverride(result, overrideCategory, mergedCategories) + } + + if (override) { + result = mergeAgentConfig(result, override) + } + + return result +} + function mergeAgentConfig( base: AgentConfig, override: AgentOverrideConfig @@ -223,9 +265,10 @@ export async function createBuiltinAgents( if (agentName === "sisyphus") continue if (agentName === "atlas") continue - if (includesCaseInsensitive(disabledAgents, agentName)) continue + if (disabledAgents.some((name) => name.toLowerCase() === agentName.toLowerCase())) continue - const override = findCaseInsensitive(agentOverrides, agentName) + const override = agentOverrides[agentName] + ?? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1] const requirement = AGENT_MODEL_REQUIREMENTS[agentName] // Check if agent requires a specific model @@ -237,10 +280,10 @@ export async function createBuiltinAgents( const isPrimaryAgent = isFactory(source) && source.mode === "primary" - const resolution = resolveModelWithFallback({ + const resolution = applyModelResolution({ uiSelectedModel: isPrimaryAgent ? uiSelectedModel : undefined, userModel: override?.model, - fallbackChain: requirement?.fallbackChain, + requirement, availableModels, systemDefaultModel, }) @@ -260,15 +303,11 @@ export async function createBuiltinAgents( config = applyCategoryOverride(config, overrideCategory, mergedCategories) } - if (agentName === "librarian" && directory && config.prompt) { - const envContext = createEnvContext() - config = { ...config, prompt: config.prompt + envContext } + if (agentName === "librarian") { + config = applyEnvironmentContext(config, directory) } - // Direct override properties take highest priority - if (override) { - config = mergeAgentConfig(config, override) - } + config = applyOverrides(config, override, mergedCategories) result[name] = config @@ -286,10 +325,10 @@ export async function createBuiltinAgents( const sisyphusOverride = agentOverrides["sisyphus"] const sisyphusRequirement = AGENT_MODEL_REQUIREMENTS["sisyphus"] - const sisyphusResolution = resolveModelWithFallback({ + const sisyphusResolution = applyModelResolution({ uiSelectedModel, userModel: sisyphusOverride?.model, - fallbackChain: sisyphusRequirement?.fallbackChain, + requirement: sisyphusRequirement, availableModels, systemDefaultModel, }) @@ -309,19 +348,8 @@ export async function createBuiltinAgents( sisyphusConfig = { ...sisyphusConfig, variant: sisyphusResolvedVariant } } - const sisOverrideCategory = (sisyphusOverride as Record | undefined)?.category as string | undefined - if (sisOverrideCategory) { - sisyphusConfig = applyCategoryOverride(sisyphusConfig, sisOverrideCategory, mergedCategories) - } - - if (directory && sisyphusConfig.prompt) { - const envContext = createEnvContext() - sisyphusConfig = { ...sisyphusConfig, prompt: sisyphusConfig.prompt + envContext } - } - - if (sisyphusOverride) { - sisyphusConfig = mergeAgentConfig(sisyphusConfig, sisyphusOverride) - } + sisyphusConfig = applyOverrides(sisyphusConfig, sisyphusOverride, mergedCategories) + sisyphusConfig = applyEnvironmentContext(sisyphusConfig, directory) result["sisyphus"] = sisyphusConfig } @@ -331,10 +359,10 @@ export async function createBuiltinAgents( const orchestratorOverride = agentOverrides["atlas"] const atlasRequirement = AGENT_MODEL_REQUIREMENTS["atlas"] - const atlasResolution = resolveModelWithFallback({ + const atlasResolution = applyModelResolution({ // NOTE: Atlas does NOT use uiSelectedModel - respects its own fallbackChain (k2p5 primary) userModel: orchestratorOverride?.model, - fallbackChain: atlasRequirement?.fallbackChain, + requirement: atlasRequirement, availableModels, systemDefaultModel, }) @@ -353,14 +381,7 @@ export async function createBuiltinAgents( orchestratorConfig = { ...orchestratorConfig, variant: atlasResolvedVariant } } - const atlasOverrideCategory = (orchestratorOverride as Record | undefined)?.category as string | undefined - if (atlasOverrideCategory) { - orchestratorConfig = applyCategoryOverride(orchestratorConfig, atlasOverrideCategory, mergedCategories) - } - - if (orchestratorOverride) { - orchestratorConfig = mergeAgentConfig(orchestratorConfig, orchestratorOverride) - } + orchestratorConfig = applyOverrides(orchestratorConfig, orchestratorOverride, mergedCategories) result["atlas"] = orchestratorConfig } diff --git a/src/hooks/claude-code-hooks/index.ts b/src/hooks/claude-code-hooks/index.ts index 2ff745558..fd1f68efa 100644 --- a/src/hooks/claude-code-hooks/index.ts +++ b/src/hooks/claude-code-hooks/index.ts @@ -24,7 +24,7 @@ import { type PreCompactContext, } from "./pre-compact" import { cacheToolInput, getToolInput } from "./tool-input-cache" -import { recordToolUse, recordToolResult, getTranscriptPath, recordUserMessage } from "./transcript" +import { appendTranscriptEntry, getTranscriptPath } from "./transcript" import type { PluginConfig } from "./types" import { log, isHookDisabled } from "../../shared" import type { ContextCollector } from "../../features/context-injector" @@ -92,7 +92,11 @@ export function createClaudeCodeHooksHook( const textParts = output.parts.filter((p) => p.type === "text" && p.text) const prompt = textParts.map((p) => p.text ?? "").join("\n") - recordUserMessage(input.sessionID, prompt) + appendTranscriptEntry(input.sessionID, { + type: "user", + timestamp: new Date().toISOString(), + content: prompt, + }) const messageParts: MessagePart[] = textParts.map((p) => ({ type: p.type as "text", @@ -198,7 +202,12 @@ export function createClaudeCodeHooksHook( const claudeConfig = await loadClaudeHooksConfig() const extendedConfig = await loadPluginExtendedConfig() - recordToolUse(input.sessionID, input.tool, output.args as Record) + appendTranscriptEntry(input.sessionID, { + type: "tool_use", + timestamp: new Date().toISOString(), + tool_name: input.tool, + tool_input: output.args as Record, + }) cacheToolInput(input.sessionID, input.tool, input.callID, output.args as Record) @@ -253,7 +262,13 @@ export function createClaudeCodeHooksHook( const metadata = output.metadata as Record | undefined const hasMetadata = metadata && typeof metadata === "object" && Object.keys(metadata).length > 0 const toolOutput = hasMetadata ? metadata : { output: output.output } - recordToolResult(input.sessionID, input.tool, cachedInput, toolOutput) + appendTranscriptEntry(input.sessionID, { + type: "tool_result", + timestamp: new Date().toISOString(), + tool_name: input.tool, + tool_input: cachedInput, + tool_output: toolOutput, + }) if (!isHookDisabled(config, "PostToolUse")) { const postClient: PostToolUseClient = { diff --git a/src/hooks/claude-code-hooks/transcript.ts b/src/hooks/claude-code-hooks/transcript.ts index 0cccd4ec5..5ee2054eb 100644 --- a/src/hooks/claude-code-hooks/transcript.ts +++ b/src/hooks/claude-code-hooks/transcript.ts @@ -28,56 +28,6 @@ export function appendTranscriptEntry( appendFileSync(path, line) } -export function recordToolUse( - sessionId: string, - toolName: string, - toolInput: Record -): void { - appendTranscriptEntry(sessionId, { - type: "tool_use", - timestamp: new Date().toISOString(), - tool_name: toolName, - tool_input: toolInput, - }) -} - -export function recordToolResult( - sessionId: string, - toolName: string, - toolInput: Record, - toolOutput: Record -): void { - appendTranscriptEntry(sessionId, { - type: "tool_result", - timestamp: new Date().toISOString(), - tool_name: toolName, - tool_input: toolInput, - tool_output: toolOutput, - }) -} - -export function recordUserMessage( - sessionId: string, - content: string -): void { - appendTranscriptEntry(sessionId, { - type: "user", - timestamp: new Date().toISOString(), - content, - }) -} - -export function recordAssistantMessage( - sessionId: string, - content: string -): void { - appendTranscriptEntry(sessionId, { - type: "assistant", - timestamp: new Date().toISOString(), - content, - }) -} - // ============================================================================ // Claude Code Compatible Transcript Builder (PORT FROM DISABLED) // ============================================================================ diff --git a/src/hooks/comment-checker/downloader.ts b/src/hooks/comment-checker/downloader.ts index b2b0b1475..8a0af844a 100644 --- a/src/hooks/comment-checker/downloader.ts +++ b/src/hooks/comment-checker/downloader.ts @@ -1,9 +1,16 @@ -import { spawn } from "bun" -import { existsSync, mkdirSync, chmodSync, unlinkSync, appendFileSync } from "fs" +import { existsSync, appendFileSync } from "fs" import { join } from "path" import { homedir, tmpdir } from "os" import { createRequire } from "module" -import { extractZip } from "../../shared" +import { + cleanupArchive, + downloadArchive, + ensureCacheDir, + ensureExecutable, + extractTarGz, + extractZipArchive, + getCachedBinaryPath as getCachedBinaryPathShared, +} from "../../shared/binary-downloader" import { log } from "../../shared/logger" const DEBUG = process.env.COMMENT_CHECKER_DEBUG === "1" @@ -60,8 +67,7 @@ export function getBinaryName(): string { * Get the cached binary path if it exists. */ export function getCachedBinaryPath(): string | null { - const binaryPath = join(getCacheDir(), getBinaryName()) - return existsSync(binaryPath) ? binaryPath : null + return getCachedBinaryPathShared(getCacheDir(), getBinaryName()) } /** @@ -78,27 +84,6 @@ function getPackageVersion(): string { } } -/** - * Extract tar.gz archive using system tar command. - */ -async function extractTarGz(archivePath: string, destDir: string): Promise { - debugLog("Extracting tar.gz:", archivePath, "to", destDir) - - const proc = spawn(["tar", "-xzf", archivePath, "-C", destDir], { - stdout: "pipe", - stderr: "pipe", - }) - - const exitCode = await proc.exited - - if (exitCode !== 0) { - const stderr = await new Response(proc.stderr).text() - throw new Error(`tar extraction failed (exit ${exitCode}): ${stderr}`) - } -} - - - /** * Download the comment-checker binary from GitHub Releases. * Returns the path to the downloaded binary, or null on failure. @@ -132,39 +117,26 @@ export async function downloadCommentChecker(): Promise { try { // Ensure cache directory exists - if (!existsSync(cacheDir)) { - mkdirSync(cacheDir, { recursive: true }) - } - - // Download with fetch() - Bun handles redirects automatically - const response = await fetch(downloadUrl, { redirect: "follow" }) - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`) - } + ensureCacheDir(cacheDir) const archivePath = join(cacheDir, assetName) - const arrayBuffer = await response.arrayBuffer() - await Bun.write(archivePath, arrayBuffer) + await downloadArchive(downloadUrl, archivePath) debugLog(`Downloaded archive to: ${archivePath}`) // Extract based on file type if (ext === "tar.gz") { + debugLog("Extracting tar.gz:", archivePath, "to", cacheDir) await extractTarGz(archivePath, cacheDir) } else { - await extractZip(archivePath, cacheDir) + await extractZipArchive(archivePath, cacheDir) } // Clean up archive - if (existsSync(archivePath)) { - unlinkSync(archivePath) - } + cleanupArchive(archivePath) // Set execute permission on Unix - if (process.platform !== "win32" && existsSync(binaryPath)) { - chmodSync(binaryPath, 0o755) - } + ensureExecutable(binaryPath) debugLog(`Successfully downloaded binary to: ${binaryPath}`) log(`[oh-my-opencode] comment-checker binary ready.`) diff --git a/src/hooks/directory-agents-injector/storage.ts b/src/hooks/directory-agents-injector/storage.ts index 38f373088..854f9ca17 100644 --- a/src/hooks/directory-agents-injector/storage.ts +++ b/src/hooks/directory-agents-injector/storage.ts @@ -1,48 +1,8 @@ -import { - existsSync, - mkdirSync, - readFileSync, - writeFileSync, - unlinkSync, -} from "node:fs"; -import { join } from "node:path"; import { AGENTS_INJECTOR_STORAGE } from "./constants"; -import type { InjectedPathsData } from "./types"; +import { createInjectedPathsStorage } from "../../shared/session-injected-paths"; -function getStoragePath(sessionID: string): string { - return join(AGENTS_INJECTOR_STORAGE, `${sessionID}.json`); -} - -export function loadInjectedPaths(sessionID: string): Set { - const filePath = getStoragePath(sessionID); - if (!existsSync(filePath)) return new Set(); - - try { - const content = readFileSync(filePath, "utf-8"); - const data: InjectedPathsData = JSON.parse(content); - return new Set(data.injectedPaths); - } catch { - return new Set(); - } -} - -export function saveInjectedPaths(sessionID: string, paths: Set): void { - if (!existsSync(AGENTS_INJECTOR_STORAGE)) { - mkdirSync(AGENTS_INJECTOR_STORAGE, { recursive: true }); - } - - const data: InjectedPathsData = { - sessionID, - injectedPaths: [...paths], - updatedAt: Date.now(), - }; - - writeFileSync(getStoragePath(sessionID), JSON.stringify(data, null, 2)); -} - -export function clearInjectedPaths(sessionID: string): void { - const filePath = getStoragePath(sessionID); - if (existsSync(filePath)) { - unlinkSync(filePath); - } -} +export const { + loadInjectedPaths, + saveInjectedPaths, + clearInjectedPaths, +} = createInjectedPathsStorage(AGENTS_INJECTOR_STORAGE); diff --git a/src/hooks/directory-agents-injector/types.ts b/src/hooks/directory-agents-injector/types.ts deleted file mode 100644 index 7544e3637..000000000 --- a/src/hooks/directory-agents-injector/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface InjectedPathsData { - sessionID: string; - injectedPaths: string[]; - updatedAt: number; -} diff --git a/src/hooks/directory-readme-injector/storage.ts b/src/hooks/directory-readme-injector/storage.ts index c4909f6e7..47aba95cf 100644 --- a/src/hooks/directory-readme-injector/storage.ts +++ b/src/hooks/directory-readme-injector/storage.ts @@ -1,48 +1,8 @@ -import { - existsSync, - mkdirSync, - readFileSync, - writeFileSync, - unlinkSync, -} from "node:fs"; -import { join } from "node:path"; import { README_INJECTOR_STORAGE } from "./constants"; -import type { InjectedPathsData } from "./types"; +import { createInjectedPathsStorage } from "../../shared/session-injected-paths"; -function getStoragePath(sessionID: string): string { - return join(README_INJECTOR_STORAGE, `${sessionID}.json`); -} - -export function loadInjectedPaths(sessionID: string): Set { - const filePath = getStoragePath(sessionID); - if (!existsSync(filePath)) return new Set(); - - try { - const content = readFileSync(filePath, "utf-8"); - const data: InjectedPathsData = JSON.parse(content); - return new Set(data.injectedPaths); - } catch { - return new Set(); - } -} - -export function saveInjectedPaths(sessionID: string, paths: Set): void { - if (!existsSync(README_INJECTOR_STORAGE)) { - mkdirSync(README_INJECTOR_STORAGE, { recursive: true }); - } - - const data: InjectedPathsData = { - sessionID, - injectedPaths: [...paths], - updatedAt: Date.now(), - }; - - writeFileSync(getStoragePath(sessionID), JSON.stringify(data, null, 2)); -} - -export function clearInjectedPaths(sessionID: string): void { - const filePath = getStoragePath(sessionID); - if (existsSync(filePath)) { - unlinkSync(filePath); - } -} +export const { + loadInjectedPaths, + saveInjectedPaths, + clearInjectedPaths, +} = createInjectedPathsStorage(README_INJECTOR_STORAGE); diff --git a/src/hooks/directory-readme-injector/types.ts b/src/hooks/directory-readme-injector/types.ts deleted file mode 100644 index 7544e3637..000000000 --- a/src/hooks/directory-readme-injector/types.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface InjectedPathsData { - sessionID: string; - injectedPaths: string[]; - updatedAt: number; -} diff --git a/src/hooks/session-notification-utils.ts b/src/hooks/session-notification-utils.ts index e3581f63a..d59a4f362 100644 --- a/src/hooks/session-notification-utils.ts +++ b/src/hooks/session-notification-utils.ts @@ -2,24 +2,6 @@ import { spawn } from "bun" type Platform = "darwin" | "linux" | "win32" | "unsupported" -let notifySendPath: string | null = null -let notifySendPromise: Promise | null = null - -let osascriptPath: string | null = null -let osascriptPromise: Promise | null = null - -let powershellPath: string | null = null -let powershellPromise: Promise | null = null - -let afplayPath: string | null = null -let afplayPromise: Promise | null = null - -let paplayPath: string | null = null -let paplayPromise: Promise | null = null - -let aplayPath: string | null = null -let aplayPromise: Promise | null = null - async function findCommand(commandName: string): Promise { const isWindows = process.platform === "win32" const cmd = isWindows ? "where" : "which" @@ -48,83 +30,30 @@ async function findCommand(commandName: string): Promise { } } -export async function getNotifySendPath(): Promise { - if (notifySendPath !== null) return notifySendPath - if (notifySendPromise) return notifySendPromise +function createCommandFinder(commandName: string): () => Promise { + let cachedPath: string | null = null + let pending: Promise | null = null - notifySendPromise = (async () => { - const path = await findCommand("notify-send") - notifySendPath = path - return path - })() + return async () => { + if (cachedPath !== null) return cachedPath + if (pending) return pending - return notifySendPromise + pending = (async () => { + const path = await findCommand(commandName) + cachedPath = path + return path + })() + + return pending + } } -export async function getOsascriptPath(): Promise { - if (osascriptPath !== null) return osascriptPath - if (osascriptPromise) return osascriptPromise - - osascriptPromise = (async () => { - const path = await findCommand("osascript") - osascriptPath = path - return path - })() - - return osascriptPromise -} - -export async function getPowershellPath(): Promise { - if (powershellPath !== null) return powershellPath - if (powershellPromise) return powershellPromise - - powershellPromise = (async () => { - const path = await findCommand("powershell") - powershellPath = path - return path - })() - - return powershellPromise -} - -export async function getAfplayPath(): Promise { - if (afplayPath !== null) return afplayPath - if (afplayPromise) return afplayPromise - - afplayPromise = (async () => { - const path = await findCommand("afplay") - afplayPath = path - return path - })() - - return afplayPromise -} - -export async function getPaplayPath(): Promise { - if (paplayPath !== null) return paplayPath - if (paplayPromise) return paplayPromise - - paplayPromise = (async () => { - const path = await findCommand("paplay") - paplayPath = path - return path - })() - - return paplayPromise -} - -export async function getAplayPath(): Promise { - if (aplayPath !== null) return aplayPath - if (aplayPromise) return aplayPromise - - aplayPromise = (async () => { - const path = await findCommand("aplay") - aplayPath = path - return path - })() - - return aplayPromise -} +export const getNotifySendPath = createCommandFinder("notify-send") +export const getOsascriptPath = createCommandFinder("osascript") +export const getPowershellPath = createCommandFinder("powershell") +export const getAfplayPath = createCommandFinder("afplay") +export const getPaplayPath = createCommandFinder("paplay") +export const getAplayPath = createCommandFinder("aplay") export function startBackgroundCheck(platform: Platform): void { if (platform === "darwin") { diff --git a/src/index.test.ts b/src/index.test.ts index 282e5232b..9ebc41482 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -1,6 +1,4 @@ import { describe, expect, it } from "bun:test" -import { includesCaseInsensitive } from "./shared" - /** * Tests for conditional tool registration logic in index.ts * @@ -13,8 +11,10 @@ describe("look_at tool conditional registration", () => { // #when checking if agent is enabled // #then should return false (disabled) it("returns false when multimodal-looker is disabled (exact case)", () => { - const disabledAgents = ["multimodal-looker"] - const isEnabled = !includesCaseInsensitive(disabledAgents, "multimodal-looker") + const disabledAgents: string[] = ["multimodal-looker"] + const isEnabled = !disabledAgents.some( + (agent) => agent.toLowerCase() === "multimodal-looker" + ) expect(isEnabled).toBe(false) }) @@ -22,8 +22,10 @@ describe("look_at tool conditional registration", () => { // #when checking if agent is enabled // #then should return false (case-insensitive match) it("returns false when multimodal-looker is disabled (case-insensitive)", () => { - const disabledAgents = ["Multimodal-Looker"] - const isEnabled = !includesCaseInsensitive(disabledAgents, "multimodal-looker") + const disabledAgents: string[] = ["Multimodal-Looker"] + const isEnabled = !disabledAgents.some( + (agent) => agent.toLowerCase() === "multimodal-looker" + ) expect(isEnabled).toBe(false) }) @@ -31,8 +33,10 @@ describe("look_at tool conditional registration", () => { // #when checking if agent is enabled // #then should return true (enabled) it("returns true when multimodal-looker is not disabled", () => { - const disabledAgents = ["oracle", "librarian"] - const isEnabled = !includesCaseInsensitive(disabledAgents, "multimodal-looker") + const disabledAgents: string[] = ["oracle", "librarian"] + const isEnabled = !disabledAgents.some( + (agent) => agent.toLowerCase() === "multimodal-looker" + ) expect(isEnabled).toBe(true) }) @@ -41,7 +45,9 @@ describe("look_at tool conditional registration", () => { // #then should return true (enabled by default) it("returns true when disabled_agents is empty", () => { const disabledAgents: string[] = [] - const isEnabled = !includesCaseInsensitive(disabledAgents, "multimodal-looker") + const isEnabled = !disabledAgents.some( + (agent) => agent.toLowerCase() === "multimodal-looker" + ) expect(isEnabled).toBe(true) }) @@ -49,8 +55,11 @@ describe("look_at tool conditional registration", () => { // #when checking if agent is enabled // #then should return true (enabled by default) it("returns true when disabled_agents is undefined (fallback to empty)", () => { - const disabledAgents = undefined - const isEnabled = !includesCaseInsensitive(disabledAgents ?? [], "multimodal-looker") + const disabledAgents: string[] | undefined = undefined + const list: string[] = disabledAgents ?? [] + const isEnabled = !list.some( + (agent) => agent.toLowerCase() === "multimodal-looker" + ) expect(isEnabled).toBe(true) }) }) diff --git a/src/index.ts b/src/index.ts index d0cd1819a..89345c131 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,8 +12,6 @@ import { createThinkModeHook, createClaudeCodeHooksHook, createAnthropicContextWindowLimitRecoveryHook, - - createCompactionContextInjector, createRulesInjectorHook, createBackgroundNotificationHook, createAutoUpdateCheckerHook, @@ -80,9 +78,9 @@ import { initTaskToastManager } from "./features/task-toast-manager"; import { TmuxSessionManager } from "./features/tmux-subagent"; import { clearBoulderState } from "./features/boulder-state"; import { type HookName } from "./config"; -import { log, detectExternalNotificationPlugin, getNotificationConflictWarning, resetMessageCursor, includesCaseInsensitive, hasConnectedProvidersCache, getOpenCodeVersion, isOpenCodeVersionAtLeast, OPENCODE_NATIVE_AGENTS_INJECTION_VERSION } from "./shared"; +import { log, detectExternalNotificationPlugin, getNotificationConflictWarning, resetMessageCursor, hasConnectedProvidersCache, getOpenCodeVersion, isOpenCodeVersionAtLeast, OPENCODE_NATIVE_AGENTS_INJECTION_VERSION } from "./shared"; import { loadPluginConfig } from "./plugin-config"; -import { createModelCacheState, getModelLimit } from "./plugin-state"; +import { createModelCacheState } from "./plugin-state"; import { createConfigHandler } from "./plugin-handlers"; const OhMyOpenCodePlugin: Plugin = async (ctx) => { @@ -176,9 +174,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { experimental: pluginConfig.experimental, }) : null; - const compactionContextInjector = isHookEnabled("compaction-context-injector") - ? createCompactionContextInjector() - : undefined; const rulesInjector = isHookEnabled("rules-injector") ? createRulesInjectorHook(ctx) : null; @@ -303,9 +298,8 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const backgroundTools = createBackgroundTools(backgroundManager, ctx.client); const callOmoAgent = createCallOmoAgent(ctx, backgroundManager); - const isMultimodalLookerEnabled = !includesCaseInsensitive( - pluginConfig.disabled_agents ?? [], - "multimodal-looker" + const isMultimodalLookerEnabled = !(pluginConfig.disabled_agents ?? []).some( + (agent) => agent.toLowerCase() === "multimodal-looker" ); const lookAt = isMultimodalLookerEnabled ? createLookAt(ctx) : null; const browserProvider = pluginConfig.browser_automation_engine?.provider ?? "playwright"; @@ -626,9 +620,8 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { if (input.tool === "task") { const args = output.args as Record; const subagentType = args.subagent_type as string; - const isExploreOrLibrarian = includesCaseInsensitive( - ["explore", "librarian"], - subagentType ?? "" + const isExploreOrLibrarian = ["explore", "librarian"].some( + (name) => name.toLowerCase() === (subagentType ?? "").toLowerCase() ); args.tools = { diff --git a/src/plugin-handlers/config-handler.ts b/src/plugin-handlers/config-handler.ts index 471ce1d81..b3ec9f1b7 100644 --- a/src/plugin-handlers/config-handler.ts +++ b/src/plugin-handlers/config-handler.ts @@ -25,11 +25,10 @@ import { loadMcpConfigs } from "../features/claude-code-mcp-loader"; import { loadAllPluginComponents } from "../features/claude-code-plugin-loader"; import { createBuiltinMcps } from "../mcp"; import type { OhMyOpenCodeConfig } from "../config"; -import { log, fetchAvailableModels, readConnectedProvidersCache } from "../shared"; +import { log, fetchAvailableModels, readConnectedProvidersCache, resolveModelPipeline } from "../shared"; import { getOpenCodeConfigPaths } from "../shared/opencode-config-dir"; import { migrateAgentConfig } from "../shared/permission-compat"; import { AGENT_NAME_MAP } from "../shared/migration"; -import { resolveModelWithFallback } from "../shared/model-resolver"; import { AGENT_MODEL_REQUIREMENTS } from "../shared/model-requirements"; import { PROMETHEUS_SYSTEM_PROMPT, PROMETHEUS_PERMISSION } from "../agents/prometheus-prompt"; import { DEFAULT_CATEGORIES } from "../tools/delegate-task/constants"; @@ -259,12 +258,16 @@ export function createConfigHandler(deps: ConfigHandlerDeps) { connectedProviders: connectedProviders ?? undefined, }); - const modelResolution = resolveModelWithFallback({ - uiSelectedModel: currentModel, - userModel: prometheusOverride?.model ?? categoryConfig?.model, - fallbackChain: prometheusRequirement?.fallbackChain, - availableModels, - systemDefaultModel: undefined, + const modelResolution = resolveModelPipeline({ + intent: { + uiSelectedModel: currentModel, + userModel: prometheusOverride?.model ?? categoryConfig?.model, + }, + constraints: { availableModels }, + policy: { + fallbackChain: prometheusRequirement?.fallbackChain, + systemDefaultModel: undefined, + }, }); const resolvedModel = modelResolution?.model; const resolvedVariant = modelResolution?.variant; diff --git a/src/shared/agent-tool-restrictions.ts b/src/shared/agent-tool-restrictions.ts index dc37fc2d4..0e58a60b9 100644 --- a/src/shared/agent-tool-restrictions.ts +++ b/src/shared/agent-tool-restrictions.ts @@ -4,8 +4,6 @@ * true = tool allowed, false = tool denied. */ -import { findCaseInsensitive } from "./case-insensitive" - const EXPLORATION_AGENT_DENYLIST: Record = { write: false, edit: false, @@ -37,10 +35,13 @@ const AGENT_RESTRICTIONS: Record> = { } export function getAgentToolRestrictions(agentName: string): Record { - return findCaseInsensitive(AGENT_RESTRICTIONS, agentName) ?? {} + return AGENT_RESTRICTIONS[agentName] + ?? Object.entries(AGENT_RESTRICTIONS).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1] + ?? {} } export function hasAgentToolRestrictions(agentName: string): boolean { - const restrictions = findCaseInsensitive(AGENT_RESTRICTIONS, agentName) + const restrictions = AGENT_RESTRICTIONS[agentName] + ?? Object.entries(AGENT_RESTRICTIONS).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1] return restrictions !== undefined && Object.keys(restrictions).length > 0 } diff --git a/src/shared/agent-variant.ts b/src/shared/agent-variant.ts index 65c27c3d7..b1c3b9c56 100644 --- a/src/shared/agent-variant.ts +++ b/src/shared/agent-variant.ts @@ -1,5 +1,4 @@ import type { OhMyOpenCodeConfig } from "../config" -import { findCaseInsensitive } from "./case-insensitive" import { AGENT_MODEL_REQUIREMENTS, CATEGORY_MODEL_REQUIREMENTS } from "./model-requirements" export function resolveAgentVariant( @@ -13,7 +12,10 @@ export function resolveAgentVariant( const agentOverrides = config.agents as | Record | undefined - const agentOverride = agentOverrides ? findCaseInsensitive(agentOverrides, agentName) : undefined + const agentOverride = agentOverrides + ? agentOverrides[agentName] + ?? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1] + : undefined if (!agentOverride) { return undefined } @@ -43,7 +45,10 @@ export function resolveVariantForModel( const agentOverrides = config.agents as | Record | undefined - const agentOverride = agentOverrides ? findCaseInsensitive(agentOverrides, agentName) : undefined + const agentOverride = agentOverrides + ? agentOverrides[agentName] + ?? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1] + : undefined const categoryName = agentOverride?.category if (categoryName) { const categoryRequirement = CATEGORY_MODEL_REQUIREMENTS[categoryName] diff --git a/src/shared/binary-downloader.ts b/src/shared/binary-downloader.ts new file mode 100644 index 000000000..a47056cab --- /dev/null +++ b/src/shared/binary-downloader.ts @@ -0,0 +1,60 @@ +import { chmodSync, existsSync, mkdirSync, unlinkSync } from "node:fs"; +import * as path from "node:path"; +import { spawn } from "bun"; +import { extractZip } from "./zip-extractor"; + +export function getCachedBinaryPath(cacheDir: string, binaryName: string): string | null { + const binaryPath = path.join(cacheDir, binaryName); + return existsSync(binaryPath) ? binaryPath : null; +} + +export function ensureCacheDir(cacheDir: string): void { + if (!existsSync(cacheDir)) { + mkdirSync(cacheDir, { recursive: true }); + } +} + +export async function downloadArchive(downloadUrl: string, archivePath: string): Promise { + const response = await fetch(downloadUrl, { redirect: "follow" }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const arrayBuffer = await response.arrayBuffer(); + await Bun.write(archivePath, arrayBuffer); +} + +export async function extractTarGz( + archivePath: string, + destDir: string, + options?: { args?: string[]; cwd?: string } +): Promise { + const args = options?.args ?? ["tar", "-xzf", archivePath, "-C", destDir]; + const proc = spawn(args, { + cwd: options?.cwd, + stdout: "pipe", + stderr: "pipe", + }); + + const exitCode = await proc.exited; + if (exitCode !== 0) { + const stderr = await new Response(proc.stderr).text(); + throw new Error(`tar extraction failed (exit ${exitCode}): ${stderr}`); + } +} + +export async function extractZipArchive(archivePath: string, destDir: string): Promise { + await extractZip(archivePath, destDir); +} + +export function cleanupArchive(archivePath: string): void { + if (existsSync(archivePath)) { + unlinkSync(archivePath); + } +} + +export function ensureExecutable(binaryPath: string): void { + if (process.platform !== "win32" && existsSync(binaryPath)) { + chmodSync(binaryPath, 0o755); + } +} diff --git a/src/shared/case-insensitive.test.ts b/src/shared/case-insensitive.test.ts deleted file mode 100644 index 0d58f2b36..000000000 --- a/src/shared/case-insensitive.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { describe, test, expect } from "bun:test" -import { - findCaseInsensitive, - includesCaseInsensitive, - findByNameCaseInsensitive, - equalsIgnoreCase, -} from "./case-insensitive" - -describe("findCaseInsensitive", () => { - test("returns undefined for empty/undefined object", () => { - // #given - undefined object - const obj = undefined - - // #when - lookup any key - const result = findCaseInsensitive(obj, "key") - - // #then - returns undefined - expect(result).toBeUndefined() - }) - - test("finds exact match first", () => { - // #given - object with exact key - const obj = { Oracle: "value1", oracle: "value2" } - - // #when - lookup with exact case - const result = findCaseInsensitive(obj, "Oracle") - - // #then - returns exact match - expect(result).toBe("value1") - }) - - test("finds case-insensitive match when no exact match", () => { - // #given - object with lowercase key - const obj = { oracle: "value" } - - // #when - lookup with uppercase - const result = findCaseInsensitive(obj, "ORACLE") - - // #then - returns case-insensitive match - expect(result).toBe("value") - }) - - test("returns undefined when key not found", () => { - // #given - object without target key - const obj = { other: "value" } - - // #when - lookup missing key - const result = findCaseInsensitive(obj, "oracle") - - // #then - returns undefined - expect(result).toBeUndefined() - }) -}) - -describe("includesCaseInsensitive", () => { - test("returns true for exact match", () => { - // #given - array with exact value - const arr = ["explore", "librarian"] - - // #when - check exact match - const result = includesCaseInsensitive(arr, "explore") - - // #then - returns true - expect(result).toBe(true) - }) - - test("returns true for case-insensitive match", () => { - // #given - array with lowercase values - const arr = ["explore", "librarian"] - - // #when - check uppercase value - const result = includesCaseInsensitive(arr, "EXPLORE") - - // #then - returns true - expect(result).toBe(true) - }) - - test("returns true for mixed case match", () => { - // #given - array with mixed case values - const arr = ["Oracle", "Sisyphus"] - - // #when - check different case - const result = includesCaseInsensitive(arr, "oracle") - - // #then - returns true - expect(result).toBe(true) - }) - - test("returns false when value not found", () => { - // #given - array without target value - const arr = ["explore", "librarian"] - - // #when - check missing value - const result = includesCaseInsensitive(arr, "oracle") - - // #then - returns false - expect(result).toBe(false) - }) - - test("returns false for empty array", () => { - // #given - empty array - const arr: string[] = [] - - // #when - check any value - const result = includesCaseInsensitive(arr, "explore") - - // #then - returns false - expect(result).toBe(false) - }) -}) - -describe("findByNameCaseInsensitive", () => { - test("finds element by exact name", () => { - // #given - array with named objects - const arr = [{ name: "Oracle", value: 1 }, { name: "explore", value: 2 }] - - // #when - find by exact name - const result = findByNameCaseInsensitive(arr, "Oracle") - - // #then - returns matching element - expect(result).toEqual({ name: "Oracle", value: 1 }) - }) - - test("finds element by case-insensitive name", () => { - // #given - array with named objects - const arr = [{ name: "Oracle", value: 1 }, { name: "explore", value: 2 }] - - // #when - find by different case - const result = findByNameCaseInsensitive(arr, "oracle") - - // #then - returns matching element - expect(result).toEqual({ name: "Oracle", value: 1 }) - }) - - test("returns undefined when name not found", () => { - // #given - array without target name - const arr = [{ name: "Oracle", value: 1 }] - - // #when - find missing name - const result = findByNameCaseInsensitive(arr, "librarian") - - // #then - returns undefined - expect(result).toBeUndefined() - }) -}) - -describe("equalsIgnoreCase", () => { - test("returns true for same case", () => { - // #given - same strings - // #when - compare - // #then - returns true - expect(equalsIgnoreCase("oracle", "oracle")).toBe(true) - }) - - test("returns true for different case", () => { - // #given - strings with different case - // #when - compare - // #then - returns true - expect(equalsIgnoreCase("Oracle", "ORACLE")).toBe(true) - expect(equalsIgnoreCase("Sisyphus-Junior", "sisyphus-junior")).toBe(true) - }) - - test("returns false for different strings", () => { - // #given - different strings - // #when - compare - // #then - returns false - expect(equalsIgnoreCase("oracle", "explore")).toBe(false) - }) -}) diff --git a/src/shared/case-insensitive.ts b/src/shared/case-insensitive.ts deleted file mode 100644 index 03951bc45..000000000 --- a/src/shared/case-insensitive.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Case-insensitive lookup and comparison utilities for agent/config names. - * Used throughout the codebase to allow "Oracle", "oracle", "ORACLE" to work the same. - */ - -/** - * Find a value in an object using case-insensitive key matching. - * First tries exact match, then falls back to lowercase comparison. - */ -export function findCaseInsensitive(obj: Record | undefined, key: string): T | undefined { - if (!obj) return undefined - const exactMatch = obj[key] - if (exactMatch !== undefined) return exactMatch - const lowerKey = key.toLowerCase() - for (const [k, v] of Object.entries(obj)) { - if (k.toLowerCase() === lowerKey) return v - } - return undefined -} - -/** - * Check if an array includes a value using case-insensitive comparison. - */ -export function includesCaseInsensitive(arr: string[], value: string): boolean { - const lowerValue = value.toLowerCase() - return arr.some((item) => item.toLowerCase() === lowerValue) -} - -/** - * Find an element in array using case-insensitive name matching. - * Useful for finding agents/categories by name. - */ -export function findByNameCaseInsensitive( - arr: T[], - name: string -): T | undefined { - const lowerName = name.toLowerCase() - return arr.find((item) => item.name.toLowerCase() === lowerName) -} - -/** - * Check if two strings are equal (case-insensitive). - */ -export function equalsIgnoreCase(a: string, b: string): boolean { - return a.toLowerCase() === b.toLowerCase() -} diff --git a/src/shared/index.ts b/src/shared/index.ts index cd67ddc55..479335125 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -20,6 +20,7 @@ export * from "./opencode-version" export * from "./permission-compat" export * from "./external-plugin-detector" export * from "./zip-extractor" +export * from "./binary-downloader" export * from "./agent-variant" export * from "./session-cursor" export * from "./shell-env" @@ -27,9 +28,14 @@ export * from "./system-directive" export * from "./agent-tool-restrictions" export * from "./model-requirements" export * from "./model-resolver" +export { + resolveModelPipeline, + type ModelResolutionRequest, + type ModelResolutionResult as ModelResolutionPipelineResult, + type ModelResolutionProvenance, +} from "./model-resolution-pipeline" export * from "./model-availability" export * from "./connected-providers-cache" -export * from "./case-insensitive" export * from "./session-utils" export * from "./tmux" export * from "./model-suggestion-retry" diff --git a/src/shared/model-resolution-pipeline.ts b/src/shared/model-resolution-pipeline.ts new file mode 100644 index 000000000..552746c87 --- /dev/null +++ b/src/shared/model-resolution-pipeline.ts @@ -0,0 +1,174 @@ +import { log } from "./logger" +import { readConnectedProvidersCache } from "./connected-providers-cache" +import { fuzzyMatchModel } from "./model-availability" +import type { FallbackEntry } from "./model-requirements" + +export type ModelResolutionRequest = { + intent?: { + uiSelectedModel?: string + userModel?: string + categoryDefaultModel?: string + } + constraints: { + availableModels: Set + } + policy?: { + fallbackChain?: FallbackEntry[] + systemDefaultModel?: string + } +} + +export type ModelResolutionProvenance = + | "override" + | "category-default" + | "provider-fallback" + | "system-default" + +export type ModelResolutionResult = { + model: string + provenance: ModelResolutionProvenance + variant?: string + attempted?: string[] + reason?: string +} + +function normalizeModel(model?: string): string | undefined { + const trimmed = model?.trim() + return trimmed || undefined +} + +export function resolveModelPipeline( + request: ModelResolutionRequest, +): ModelResolutionResult | undefined { + const attempted: string[] = [] + const { intent, constraints, policy } = request + const availableModels = constraints.availableModels + const fallbackChain = policy?.fallbackChain + const systemDefaultModel = policy?.systemDefaultModel + + const normalizedUiModel = normalizeModel(intent?.uiSelectedModel) + if (normalizedUiModel) { + log("Model resolved via UI selection", { model: normalizedUiModel }) + return { model: normalizedUiModel, provenance: "override" } + } + + const normalizedUserModel = normalizeModel(intent?.userModel) + if (normalizedUserModel) { + log("Model resolved via config override", { model: normalizedUserModel }) + return { model: normalizedUserModel, provenance: "override" } + } + + const normalizedCategoryDefault = normalizeModel(intent?.categoryDefaultModel) + if (normalizedCategoryDefault) { + attempted.push(normalizedCategoryDefault) + if (availableModels.size > 0) { + const parts = normalizedCategoryDefault.split("/") + const providerHint = parts.length >= 2 ? [parts[0]] : undefined + const match = fuzzyMatchModel(normalizedCategoryDefault, availableModels, providerHint) + if (match) { + log("Model resolved via category default (fuzzy matched)", { + original: normalizedCategoryDefault, + matched: match, + }) + return { model: match, provenance: "category-default", attempted } + } + } else { + const connectedProviders = readConnectedProvidersCache() + if (connectedProviders === null) { + log("Model resolved via category default (no cache, first run)", { + model: normalizedCategoryDefault, + }) + return { model: normalizedCategoryDefault, provenance: "category-default", attempted } + } + const parts = normalizedCategoryDefault.split("/") + if (parts.length >= 2) { + const provider = parts[0] + if (connectedProviders.includes(provider)) { + log("Model resolved via category default (connected provider)", { + model: normalizedCategoryDefault, + }) + return { model: normalizedCategoryDefault, provenance: "category-default", attempted } + } + } + } + log("Category default model not available, falling through to fallback chain", { + model: normalizedCategoryDefault, + }) + } + + if (fallbackChain && fallbackChain.length > 0) { + if (availableModels.size === 0) { + const connectedProviders = readConnectedProvidersCache() + const connectedSet = connectedProviders ? new Set(connectedProviders) : null + + if (connectedSet === null) { + log("Model fallback chain skipped (no connected providers cache) - falling through to system default") + } else { + for (const entry of fallbackChain) { + for (const provider of entry.providers) { + if (connectedSet.has(provider)) { + const model = `${provider}/${entry.model}` + log("Model resolved via fallback chain (connected provider)", { + provider, + model: entry.model, + variant: entry.variant, + }) + return { + model, + provenance: "provider-fallback", + variant: entry.variant, + attempted, + } + } + } + } + log("No connected provider found in fallback chain, falling through to system default") + } + } else { + for (const entry of fallbackChain) { + for (const provider of entry.providers) { + const fullModel = `${provider}/${entry.model}` + const match = fuzzyMatchModel(fullModel, availableModels, [provider]) + if (match) { + log("Model resolved via fallback chain (availability confirmed)", { + provider, + model: entry.model, + match, + variant: entry.variant, + }) + return { + model: match, + provenance: "provider-fallback", + variant: entry.variant, + attempted, + } + } + } + + const crossProviderMatch = fuzzyMatchModel(entry.model, availableModels) + if (crossProviderMatch) { + log("Model resolved via fallback chain (cross-provider fuzzy match)", { + model: entry.model, + match: crossProviderMatch, + variant: entry.variant, + }) + return { + model: crossProviderMatch, + provenance: "provider-fallback", + variant: entry.variant, + attempted, + } + } + } + log("No available model found in fallback chain, falling through to system default") + } + } + + if (systemDefaultModel === undefined) { + log("No model resolved - systemDefaultModel not configured") + return undefined + } + + log("Model resolved via system default", { model: systemDefaultModel }) + return { model: systemDefaultModel, provenance: "system-default", attempted } +} diff --git a/src/shared/model-resolver.ts b/src/shared/model-resolver.ts index fbe1a807f..84bc17d18 100644 --- a/src/shared/model-resolver.ts +++ b/src/shared/model-resolver.ts @@ -1,7 +1,6 @@ import { log } from "./logger" -import { fuzzyMatchModel } from "./model-availability" import type { FallbackEntry } from "./model-requirements" -import { readConnectedProvidersCache } from "./connected-providers-cache" +import { resolveModelPipeline } from "./model-resolution-pipeline" export type ModelResolutionInput = { userModel?: string @@ -47,107 +46,19 @@ export function resolveModelWithFallback( input: ExtendedModelResolutionInput, ): ModelResolutionResult | undefined { const { uiSelectedModel, userModel, categoryDefaultModel, fallbackChain, availableModels, systemDefaultModel } = input + const resolved = resolveModelPipeline({ + intent: { uiSelectedModel, userModel, categoryDefaultModel }, + constraints: { availableModels }, + policy: { fallbackChain, systemDefaultModel }, + }) - // Step 1: UI Selection (highest priority - respects user's model choice in OpenCode UI) - const normalizedUiModel = normalizeModel(uiSelectedModel) - if (normalizedUiModel) { - log("Model resolved via UI selection", { model: normalizedUiModel }) - return { model: normalizedUiModel, source: "override" } - } - - // Step 2: Config Override (from oh-my-opencode.json user config) - const normalizedUserModel = normalizeModel(userModel) - if (normalizedUserModel) { - log("Model resolved via config override", { model: normalizedUserModel }) - return { model: normalizedUserModel, source: "override" } - } - - // Step 2.5: Category Default Model (from DEFAULT_CATEGORIES, with fuzzy matching) - const normalizedCategoryDefault = normalizeModel(categoryDefaultModel) - if (normalizedCategoryDefault) { - if (availableModels.size > 0) { - const parts = normalizedCategoryDefault.split("/") - const providerHint = parts.length >= 2 ? [parts[0]] : undefined - const match = fuzzyMatchModel(normalizedCategoryDefault, availableModels, providerHint) - if (match) { - log("Model resolved via category default (fuzzy matched)", { original: normalizedCategoryDefault, matched: match }) - return { model: match, source: "category-default" } - } - } else { - const connectedProviders = readConnectedProvidersCache() - if (connectedProviders === null) { - log("Model resolved via category default (no cache, first run)", { model: normalizedCategoryDefault }) - return { model: normalizedCategoryDefault, source: "category-default" } - } - const parts = normalizedCategoryDefault.split("/") - if (parts.length >= 2) { - const provider = parts[0] - if (connectedProviders.includes(provider)) { - log("Model resolved via category default (connected provider)", { model: normalizedCategoryDefault }) - return { model: normalizedCategoryDefault, source: "category-default" } - } - } - } - log("Category default model not available, falling through to fallback chain", { model: normalizedCategoryDefault }) - } - - // Step 3: Provider fallback chain (exact match → fuzzy match → next provider) - if (fallbackChain && fallbackChain.length > 0) { - if (availableModels.size === 0) { - const connectedProviders = readConnectedProvidersCache() - const connectedSet = connectedProviders ? new Set(connectedProviders) : null - - if (connectedSet === null) { - log("Model fallback chain skipped (no connected providers cache) - falling through to system default") - } else { - for (const entry of fallbackChain) { - for (const provider of entry.providers) { - if (connectedSet.has(provider)) { - const model = `${provider}/${entry.model}` - log("Model resolved via fallback chain (connected provider)", { - provider, - model: entry.model, - variant: entry.variant, - }) - return { model, source: "provider-fallback", variant: entry.variant } - } - } - } - log("No connected provider found in fallback chain, falling through to system default") - } - } else { - for (const entry of fallbackChain) { - // Step 1: Try with provider filter (preferred providers first) - for (const provider of entry.providers) { - const fullModel = `${provider}/${entry.model}` - const match = fuzzyMatchModel(fullModel, availableModels, [provider]) - if (match) { - log("Model resolved via fallback chain (availability confirmed)", { provider, model: entry.model, match, variant: entry.variant }) - return { model: match, source: "provider-fallback", variant: entry.variant } - } - } - - // Step 2: Try without provider filter (cross-provider fuzzy match) - const crossProviderMatch = fuzzyMatchModel(entry.model, availableModels) - if (crossProviderMatch) { - log("Model resolved via fallback chain (cross-provider fuzzy match)", { - model: entry.model, - match: crossProviderMatch, - variant: entry.variant, - }) - return { model: crossProviderMatch, source: "provider-fallback", variant: entry.variant } - } - } - log("No available model found in fallback chain, falling through to system default") - } - } - - // Step 4: System default (if provided) - if (systemDefaultModel === undefined) { - log("No model resolved - systemDefaultModel not configured") + if (!resolved) { return undefined } - log("Model resolved via system default", { model: systemDefaultModel }) - return { model: systemDefaultModel, source: "system-default" } + return { + model: resolved.model, + source: resolved.provenance, + variant: resolved.variant, + } } diff --git a/src/shared/opencode-version.test.ts b/src/shared/opencode-version.test.ts index f5c576235..020ccb311 100644 --- a/src/shared/opencode-version.test.ts +++ b/src/shared/opencode-version.test.ts @@ -2,8 +2,6 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test" import { parseVersion, compareVersions, - isVersionGte, - isVersionLt, getOpenCodeVersion, isOpenCodeVersionAtLeast, resetVersionCache, @@ -103,32 +101,6 @@ describe("opencode-version", () => { }) }) - describe("isVersionGte", () => { - test("returns true when a >= b", () => { - expect(isVersionGte("1.1.1", "1.1.1")).toBe(true) - expect(isVersionGte("1.1.2", "1.1.1")).toBe(true) - expect(isVersionGte("1.2.0", "1.1.1")).toBe(true) - expect(isVersionGte("2.0.0", "1.1.1")).toBe(true) - }) - - test("returns false when a < b", () => { - expect(isVersionGte("1.1.0", "1.1.1")).toBe(false) - expect(isVersionGte("1.0.9", "1.1.1")).toBe(false) - expect(isVersionGte("0.9.9", "1.1.1")).toBe(false) - }) - }) - - describe("isVersionLt", () => { - test("returns true when a < b", () => { - expect(isVersionLt("1.1.0", "1.1.1")).toBe(true) - expect(isVersionLt("1.0.150", "1.1.1")).toBe(true) - }) - - test("returns false when a >= b", () => { - expect(isVersionLt("1.1.1", "1.1.1")).toBe(false) - expect(isVersionLt("1.1.2", "1.1.1")).toBe(false) - }) - }) describe("getOpenCodeVersion", () => { beforeEach(() => { diff --git a/src/shared/opencode-version.ts b/src/shared/opencode-version.ts index dc9b88831..f02161ac0 100644 --- a/src/shared/opencode-version.ts +++ b/src/shared/opencode-version.ts @@ -37,13 +37,6 @@ export function compareVersions(a: string, b: string): -1 | 0 | 1 { return 0 } -export function isVersionGte(a: string, b: string): boolean { - return compareVersions(a, b) >= 0 -} - -export function isVersionLt(a: string, b: string): boolean { - return compareVersions(a, b) < 0 -} export function getOpenCodeVersion(): string | null { if (cachedVersion !== NOT_CACHED) { @@ -69,7 +62,7 @@ export function getOpenCodeVersion(): string | null { export function isOpenCodeVersionAtLeast(version: string): boolean { const current = getOpenCodeVersion() if (!current) return true - return isVersionGte(current, version) + return compareVersions(current, version) >= 0 } export function resetVersionCache(): void { diff --git a/src/shared/session-injected-paths.ts b/src/shared/session-injected-paths.ts new file mode 100644 index 000000000..8a337dd1c --- /dev/null +++ b/src/shared/session-injected-paths.ts @@ -0,0 +1,59 @@ +import { + existsSync, + mkdirSync, + readFileSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; + +export interface InjectedPathsData { + sessionID: string; + injectedPaths: string[]; + updatedAt: number; +} + +export function createInjectedPathsStorage(storageDir: string) { + const getStoragePath = (sessionID: string): string => + join(storageDir, `${sessionID}.json`); + + const loadInjectedPaths = (sessionID: string): Set => { + const filePath = getStoragePath(sessionID); + if (!existsSync(filePath)) return new Set(); + + try { + const content = readFileSync(filePath, "utf-8"); + const data: InjectedPathsData = JSON.parse(content); + return new Set(data.injectedPaths); + } catch { + return new Set(); + } + }; + + const saveInjectedPaths = (sessionID: string, paths: Set): void => { + if (!existsSync(storageDir)) { + mkdirSync(storageDir, { recursive: true }); + } + + const data: InjectedPathsData = { + sessionID, + injectedPaths: [...paths], + updatedAt: Date.now(), + }; + + writeFileSync(getStoragePath(sessionID), JSON.stringify(data, null, 2)); + }; + + const clearInjectedPaths = (sessionID: string): void => { + const filePath = getStoragePath(sessionID); + if (existsSync(filePath)) { + unlinkSync(filePath); + } + }; + + return { + loadInjectedPaths, + saveInjectedPaths, + clearInjectedPaths, + }; +} diff --git a/src/shared/snake-case.ts b/src/shared/snake-case.ts index cb247071c..8b9ec5a13 100644 --- a/src/shared/snake-case.ts +++ b/src/shared/snake-case.ts @@ -8,42 +8,37 @@ export function snakeToCamel(str: string): string { return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()) } +export function transformObjectKeys( + obj: Record, + transformer: (key: string) => string, + deep: boolean = true +): Record { + const result: Record = {} + for (const [key, value] of Object.entries(obj)) { + const transformedKey = transformer(key) + if (deep && isPlainObject(value)) { + result[transformedKey] = transformObjectKeys(value, transformer, true) + } else if (deep && Array.isArray(value)) { + result[transformedKey] = value.map((item) => + isPlainObject(item) ? transformObjectKeys(item, transformer, true) : item + ) + } else { + result[transformedKey] = value + } + } + return result +} + export function objectToSnakeCase( obj: Record, deep: boolean = true ): Record { - const result: Record = {} - for (const [key, value] of Object.entries(obj)) { - const snakeKey = camelToSnake(key) - if (deep && isPlainObject(value)) { - result[snakeKey] = objectToSnakeCase(value, true) - } else if (deep && Array.isArray(value)) { - result[snakeKey] = value.map((item) => - isPlainObject(item) ? objectToSnakeCase(item, true) : item - ) - } else { - result[snakeKey] = value - } - } - return result - } + return transformObjectKeys(obj, camelToSnake, deep) +} export function objectToCamelCase( obj: Record, deep: boolean = true ): Record { - const result: Record = {} - for (const [key, value] of Object.entries(obj)) { - const camelKey = snakeToCamel(key) - if (deep && isPlainObject(value)) { - result[camelKey] = objectToCamelCase(value, true) - } else if (deep && Array.isArray(value)) { - result[camelKey] = value.map((item) => - isPlainObject(item) ? objectToCamelCase(item, true) : item - ) - } else { - result[camelKey] = value - } - } - return result - } + return transformObjectKeys(obj, snakeToCamel, deep) +} diff --git a/src/tools/ast-grep/downloader.ts b/src/tools/ast-grep/downloader.ts index d1addb184..a05c4f16f 100644 --- a/src/tools/ast-grep/downloader.ts +++ b/src/tools/ast-grep/downloader.ts @@ -1,8 +1,15 @@ -import { existsSync, mkdirSync, chmodSync, unlinkSync } from "fs" +import { existsSync } from "fs" import { join } from "path" import { homedir } from "os" import { createRequire } from "module" -import { extractZip } from "../../shared" +import { + cleanupArchive, + downloadArchive, + ensureCacheDir, + ensureExecutable, + extractZipArchive, + getCachedBinaryPath as getCachedBinaryPathShared, +} from "../../shared/binary-downloader" import { log } from "../../shared/logger" const REPO = "ast-grep/ast-grep" @@ -53,8 +60,7 @@ export function getBinaryName(): string { } export function getCachedBinaryPath(): string | null { - const binaryPath = join(getCacheDir(), getBinaryName()) - return existsSync(binaryPath) ? binaryPath : null + return getCachedBinaryPathShared(getCacheDir(), getBinaryName()) } @@ -83,29 +89,12 @@ export async function downloadAstGrep(version: string = DEFAULT_VERSION): Promis log(`[oh-my-opencode] Downloading ast-grep binary...`) try { - if (!existsSync(cacheDir)) { - mkdirSync(cacheDir, { recursive: true }) - } - - const response = await fetch(downloadUrl, { redirect: "follow" }) - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`) - } - const archivePath = join(cacheDir, assetName) - const arrayBuffer = await response.arrayBuffer() - await Bun.write(archivePath, arrayBuffer) - - await extractZip(archivePath, cacheDir) - - if (existsSync(archivePath)) { - unlinkSync(archivePath) - } - - if (process.platform !== "win32" && existsSync(binaryPath)) { - chmodSync(binaryPath, 0o755) - } + ensureCacheDir(cacheDir) + await downloadArchive(downloadUrl, archivePath) + await extractZipArchive(archivePath, cacheDir) + cleanupArchive(archivePath) + ensureExecutable(binaryPath) log(`[oh-my-opencode] ast-grep binary ready.`) diff --git a/src/tools/call-omo-agent/tools.ts b/src/tools/call-omo-agent/tools.ts index 7de7ff310..bb00f1a28 100644 --- a/src/tools/call-omo-agent/tools.ts +++ b/src/tools/call-omo-agent/tools.ts @@ -4,7 +4,7 @@ import { join } from "node:path" import { ALLOWED_AGENTS, CALL_OMO_AGENT_DESCRIPTION } from "./constants" import type { CallOmoAgentArgs } from "./types" import type { BackgroundManager } from "../../features/background-agent" -import { log, getAgentToolRestrictions, includesCaseInsensitive } from "../../shared" +import { log, getAgentToolRestrictions } from "../../shared" import { consumeNewMessages } from "../../shared/session-cursor" import { findFirstMessageWithAgent, findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector" import { getSessionAgent } from "../../features/claude-code-session-state" @@ -58,7 +58,9 @@ export function createCallOmoAgent( log(`[call_omo_agent] Starting with agent: ${args.subagent_type}, background: ${args.run_in_background}`) // Case-insensitive agent validation - allows "Explore", "EXPLORE", "explore" etc. - if (!includesCaseInsensitive([...ALLOWED_AGENTS], args.subagent_type)) { + if (![...ALLOWED_AGENTS].some( + (name) => name.toLowerCase() === args.subagent_type.toLowerCase() + )) { return `Error: Invalid agent type "${args.subagent_type}". Only ${ALLOWED_AGENTS.join(", ")} are allowed.` } diff --git a/src/tools/delegate-task/tools.ts b/src/tools/delegate-task/tools.ts index eb77fe9af..0ff8dec79 100644 --- a/src/tools/delegate-task/tools.ts +++ b/src/tools/delegate-task/tools.ts @@ -12,10 +12,9 @@ import { discoverSkills } from "../../features/opencode-skill-loader" import { getTaskToastManager } from "../../features/task-toast-manager" import type { ModelFallbackInfo } from "../../features/task-toast-manager/types" import { subagentSessions, getSessionAgent } from "../../features/claude-code-session-state" -import { log, getAgentToolRestrictions, resolveModel, getOpenCodeConfigPaths, findByNameCaseInsensitive, equalsIgnoreCase, promptWithModelSuggestionRetry } from "../../shared" +import { log, getAgentToolRestrictions, resolveModel, resolveModelPipeline, getOpenCodeConfigPaths, promptWithModelSuggestionRetry } from "../../shared" import { fetchAvailableModels, isModelAvailable } from "../../shared/model-availability" import { readConnectedProvidersCache } from "../../shared/connected-providers-cache" -import { resolveModelWithFallback } from "../../shared/model-resolver" import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements" type OpencodeClient = PluginInput["client"] @@ -552,16 +551,20 @@ To continue this session: session_id="${args.session_id}"` modelInfo = { model: actualModel, type: "system-default", source: "system-default" } } } else { - const resolution = resolveModelWithFallback({ + const resolution = resolveModelPipeline({ + intent: { userModel: userCategories?.[args.category]?.model, categoryDefaultModel: resolved.model ?? sisyphusJuniorModel, + }, + constraints: { availableModels }, + policy: { fallbackChain: requirement.fallbackChain, - availableModels, systemDefaultModel, - }) + }, + }) - if (resolution) { - const { model: resolvedModel, source, variant: resolvedVariant } = resolution + if (resolution) { + const { model: resolvedModel, provenance, variant: resolvedVariant } = resolution actualModel = resolvedModel if (!parseModelString(actualModel)) { @@ -569,7 +572,8 @@ To continue this session: session_id="${args.session_id}"` } let type: "user-defined" | "inherited" | "category-default" | "system-default" - switch (source) { + const source = provenance + switch (provenance) { case "override": type = "user-defined" break @@ -582,7 +586,7 @@ To continue this session: session_id="${args.session_id}"` break } - modelInfo = { model: actualModel, type, source } + modelInfo = { model: actualModel, type, source } const parsedModel = parseModelString(actualModel) const variantToUse = userCategories?.[args.category]?.variant ?? resolvedVariant ?? resolved.config.variant @@ -780,7 +784,7 @@ To continue this session: session_id="${sessionID}"` } const agentName = args.subagent_type.trim() - if (equalsIgnoreCase(agentName, SISYPHUS_JUNIOR_AGENT)) { + if (agentName.toLowerCase() === SISYPHUS_JUNIOR_AGENT.toLowerCase()) { return `Cannot use subagent_type="${SISYPHUS_JUNIOR_AGENT}" directly. Use category parameter instead (e.g., ${categoryExamples}). Sisyphus-Junior is spawned automatically when you specify a category. Pick the appropriate category for your task domain.` @@ -803,12 +807,13 @@ Create the work plan directly - that's your job as the planning agent.` const callableAgents = agents.filter((a) => a.mode !== "primary") - const matchedAgent = findByNameCaseInsensitive(callableAgents, agentToUse) + const matchedAgent = callableAgents.find( + (agent) => agent.name.toLowerCase() === agentToUse.toLowerCase() + ) if (!matchedAgent) { - const isPrimaryAgent = findByNameCaseInsensitive( - agents.filter((a) => a.mode === "primary"), - agentToUse - ) + const isPrimaryAgent = agents + .filter((a) => a.mode === "primary") + .find((agent) => agent.name.toLowerCase() === agentToUse.toLowerCase()) if (isPrimaryAgent) { return `Cannot call primary agent "${isPrimaryAgent.name}" via delegate_task. Primary agents are top-level orchestrators.` } diff --git a/src/tools/grep/downloader.ts b/src/tools/grep/downloader.ts index 350739c89..774740b83 100644 --- a/src/tools/grep/downloader.ts +++ b/src/tools/grep/downloader.ts @@ -1,7 +1,13 @@ -import { existsSync, mkdirSync, chmodSync, unlinkSync, readdirSync } from "node:fs" +import { existsSync, readdirSync } from "node:fs" import { join } from "node:path" -import { spawn } from "bun" import { extractZip as extractZipBase } from "../../shared" +import { + cleanupArchive, + downloadArchive, + ensureCacheDir, + ensureExecutable, + extractTarGz as extractTarGzArchive, +} from "../../shared/binary-downloader" export function findFileRecursive(dir: string, filename: string): string | null { try { @@ -41,16 +47,6 @@ function getRgPath(): string { return join(getInstallDir(), isWindows ? "rg.exe" : "rg") } -async function downloadFile(url: string, destPath: string): Promise { - const response = await fetch(url) - if (!response.ok) { - throw new Error(`Failed to download: ${response.status} ${response.statusText}`) - } - - const buffer = await response.arrayBuffer() - await Bun.write(destPath, buffer) -} - async function extractTarGz(archivePath: string, destDir: string): Promise { const platformKey = getPlatformKey() @@ -62,17 +58,7 @@ async function extractTarGz(archivePath: string, destDir: string): Promise args.push("--wildcards", "*/rg") } - const proc = spawn(args, { - cwd: destDir, - stdout: "pipe", - stderr: "pipe", - }) - - const exitCode = await proc.exited - if (exitCode !== 0) { - const stderr = await new Response(proc.stderr).text() - throw new Error(`Failed to extract tar.gz: ${stderr}`) - } + await extractTarGzArchive(archivePath, destDir, { args, cwd: destDir }) } async function extractZip(archivePath: string, destDir: string): Promise { @@ -104,14 +90,14 @@ export async function downloadAndInstallRipgrep(): Promise { return rgPath } - mkdirSync(installDir, { recursive: true }) + ensureCacheDir(installDir) const filename = `ripgrep-${RG_VERSION}-${config.platform}.${config.extension}` const url = `https://github.com/BurntSushi/ripgrep/releases/download/${RG_VERSION}/${filename}` const archivePath = join(installDir, filename) try { - await downloadFile(url, archivePath) + await downloadArchive(url, archivePath) if (config.extension === "tar.gz") { await extractTarGz(archivePath, installDir) @@ -119,9 +105,7 @@ export async function downloadAndInstallRipgrep(): Promise { await extractZip(archivePath, installDir) } - if (process.platform !== "win32") { - chmodSync(rgPath, 0o755) - } + ensureExecutable(rgPath) if (!existsSync(rgPath)) { throw new Error("ripgrep binary not found after extraction") @@ -129,12 +113,10 @@ export async function downloadAndInstallRipgrep(): Promise { return rgPath } finally { - if (existsSync(archivePath)) { - try { - unlinkSync(archivePath) - } catch { - // Cleanup failures are non-critical - } + try { + cleanupArchive(archivePath) + } catch { + // Cleanup failures are non-critical } } } diff --git a/src/tools/look-at/tools.ts b/src/tools/look-at/tools.ts index 6715aff11..ef64ad86a 100644 --- a/src/tools/look-at/tools.ts +++ b/src/tools/look-at/tools.ts @@ -3,7 +3,7 @@ import { pathToFileURL } from "node:url" import { tool, type PluginInput, type ToolDefinition } from "@opencode-ai/plugin" import { LOOK_AT_DESCRIPTION, MULTIMODAL_LOOKER_AGENT } from "./constants" import type { LookAtArgs } from "./types" -import { findByNameCaseInsensitive, log, promptWithModelSuggestionRetry } from "../../shared" +import { log, promptWithModelSuggestionRetry } from "../../shared" interface LookAtArgsWithAlias extends LookAtArgs { path?: string @@ -143,7 +143,9 @@ Original error: ${createResult.error}` } const agents = ((agentsResult as { data?: AgentInfo[] })?.data ?? agentsResult) as AgentInfo[] | undefined if (agents?.length) { - const matchedAgent = findByNameCaseInsensitive(agents, MULTIMODAL_LOOKER_AGENT) + const matchedAgent = agents.find( + (agent) => agent.name.toLowerCase() === MULTIMODAL_LOOKER_AGENT.toLowerCase() + ) if (matchedAgent?.model) { agentModel = matchedAgent.model }