Consolidate duplicate patterns and simplify codebase (#1317)
* refactor(shared): unify binary downloader and session path storage - Create binary-downloader.ts for common download/extract logic - Create session-injected-paths.ts for unified path tracking - Refactor comment-checker, ast-grep, grep downloaders to use shared util - Consolidate directory injector types into shared module * feat(shared): implement unified model resolution pipeline - Create ModelResolutionPipeline for centralized model selection - Refactor model-resolver to use pipeline - Update delegate-task and config-handler to use unified logic - Ensure consistent model resolution across all agent types * refactor(agents): simplify agent utils and metadata management - Extract helper functions for config merging and env context - Register prompt metadata for all agents - Simplify agent variant detection logic * cleanup: inline utilities and remove unused exports - Remove case-insensitive.ts (inline with native JS) - Simplify opencode-version helpers - Remove unused getModelLimit, createCompactionContextInjector exports - Inline transcript entry creation in claude-code-hooks - Update tests accordingly --------- Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com>
This commit is contained in:
28
bun.lock
28
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=="],
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Record<BuiltinAgentName, AgentPromptMetadata>> = {
|
||||
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<string>
|
||||
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<string, CategoryConfig>
|
||||
): AgentConfig {
|
||||
let result = config
|
||||
const overrideCategory = (override as Record<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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
|
||||
}
|
||||
|
||||
@@ -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<string, unknown>)
|
||||
appendTranscriptEntry(input.sessionID, {
|
||||
type: "tool_use",
|
||||
timestamp: new Date().toISOString(),
|
||||
tool_name: input.tool,
|
||||
tool_input: output.args as Record<string, unknown>,
|
||||
})
|
||||
|
||||
cacheToolInput(input.sessionID, input.tool, input.callID, output.args as Record<string, unknown>)
|
||||
|
||||
@@ -253,7 +262,13 @@ export function createClaudeCodeHooksHook(
|
||||
const metadata = output.metadata as Record<string, unknown> | 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 = {
|
||||
|
||||
@@ -28,56 +28,6 @@ export function appendTranscriptEntry(
|
||||
appendFileSync(path, line)
|
||||
}
|
||||
|
||||
export function recordToolUse(
|
||||
sessionId: string,
|
||||
toolName: string,
|
||||
toolInput: Record<string, unknown>
|
||||
): 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<string, unknown>,
|
||||
toolOutput: Record<string, unknown>
|
||||
): 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)
|
||||
// ============================================================================
|
||||
|
||||
@@ -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<void> {
|
||||
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<string | null> {
|
||||
|
||||
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.`)
|
||||
|
||||
@@ -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<string> {
|
||||
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<string>): 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);
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export interface InjectedPathsData {
|
||||
sessionID: string;
|
||||
injectedPaths: string[];
|
||||
updatedAt: number;
|
||||
}
|
||||
@@ -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<string> {
|
||||
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<string>): 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);
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export interface InjectedPathsData {
|
||||
sessionID: string;
|
||||
injectedPaths: string[];
|
||||
updatedAt: number;
|
||||
}
|
||||
@@ -2,24 +2,6 @@ import { spawn } from "bun"
|
||||
|
||||
type Platform = "darwin" | "linux" | "win32" | "unsupported"
|
||||
|
||||
let notifySendPath: string | null = null
|
||||
let notifySendPromise: Promise<string | null> | null = null
|
||||
|
||||
let osascriptPath: string | null = null
|
||||
let osascriptPromise: Promise<string | null> | null = null
|
||||
|
||||
let powershellPath: string | null = null
|
||||
let powershellPromise: Promise<string | null> | null = null
|
||||
|
||||
let afplayPath: string | null = null
|
||||
let afplayPromise: Promise<string | null> | null = null
|
||||
|
||||
let paplayPath: string | null = null
|
||||
let paplayPromise: Promise<string | null> | null = null
|
||||
|
||||
let aplayPath: string | null = null
|
||||
let aplayPromise: Promise<string | null> | null = null
|
||||
|
||||
async function findCommand(commandName: string): Promise<string | null> {
|
||||
const isWindows = process.platform === "win32"
|
||||
const cmd = isWindows ? "where" : "which"
|
||||
@@ -48,83 +30,30 @@ async function findCommand(commandName: string): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getNotifySendPath(): Promise<string | null> {
|
||||
if (notifySendPath !== null) return notifySendPath
|
||||
if (notifySendPromise) return notifySendPromise
|
||||
function createCommandFinder(commandName: string): () => Promise<string | null> {
|
||||
let cachedPath: string | null = null
|
||||
let pending: Promise<string | null> | 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<string | null> {
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
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") {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
19
src/index.ts
19
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<string, unknown>;
|
||||
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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
* true = tool allowed, false = tool denied.
|
||||
*/
|
||||
|
||||
import { findCaseInsensitive } from "./case-insensitive"
|
||||
|
||||
const EXPLORATION_AGENT_DENYLIST: Record<string, boolean> = {
|
||||
write: false,
|
||||
edit: false,
|
||||
@@ -37,10 +35,13 @@ const AGENT_RESTRICTIONS: Record<string, Record<string, boolean>> = {
|
||||
}
|
||||
|
||||
export function getAgentToolRestrictions(agentName: string): Record<string, boolean> {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<string, { variant?: string; category?: string }>
|
||||
| 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<string, { category?: string }>
|
||||
| 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]
|
||||
|
||||
60
src/shared/binary-downloader.ts
Normal file
60
src/shared/binary-downloader.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<T>(obj: Record<string, T> | 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<T extends { name: string }>(
|
||||
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()
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
174
src/shared/model-resolution-pipeline.ts
Normal file
174
src/shared/model-resolution-pipeline.ts
Normal file
@@ -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<string>
|
||||
}
|
||||
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 }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
59
src/shared/session-injected-paths.ts
Normal file
59
src/shared/session-injected-paths.ts
Normal file
@@ -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<string> => {
|
||||
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<string>): 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,
|
||||
};
|
||||
}
|
||||
@@ -8,42 +8,37 @@ export function snakeToCamel(str: string): string {
|
||||
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
|
||||
}
|
||||
|
||||
export function transformObjectKeys(
|
||||
obj: Record<string, unknown>,
|
||||
transformer: (key: string) => string,
|
||||
deep: boolean = true
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {}
|
||||
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<string, unknown>,
|
||||
deep: boolean = true
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {}
|
||||
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<string, unknown>,
|
||||
deep: boolean = true
|
||||
): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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.`)
|
||||
|
||||
|
||||
@@ -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.`
|
||||
}
|
||||
|
||||
|
||||
@@ -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.`
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
const platformKey = getPlatformKey()
|
||||
|
||||
@@ -62,17 +58,7 @@ async function extractTarGz(archivePath: string, destDir: string): Promise<void>
|
||||
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<void> {
|
||||
@@ -104,14 +90,14 @@ export async function downloadAndInstallRipgrep(): Promise<string> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
|
||||
return rgPath
|
||||
} finally {
|
||||
if (existsSync(archivePath)) {
|
||||
try {
|
||||
unlinkSync(archivePath)
|
||||
} catch {
|
||||
// Cleanup failures are non-critical
|
||||
}
|
||||
try {
|
||||
cleanupArchive(archivePath)
|
||||
} catch {
|
||||
// Cleanup failures are non-critical
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user