refactor: wave 1 - extract leaf modules, rename catch-all files, split index.ts hooks
- Split 25+ index.ts files into hook.ts + extracted modules - Rename all catch-all utils.ts/helpers.ts to domain-specific names - Split src/tools/lsp/ into ~15 focused modules - Split src/tools/delegate-task/ into ~18 focused modules - Separate shared types from implementation - 155 files changed, 60+ new files created - All typecheck clean, 61 tests pass
This commit is contained in:
parent
71ac54c33e
commit
29155ec7bc
28
bun.lock
28
bun.lock
@ -28,13 +28,13 @@
|
||||
"typescript": "^5.7.3",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"oh-my-opencode-darwin-arm64": "3.3.0",
|
||||
"oh-my-opencode-darwin-x64": "3.3.0",
|
||||
"oh-my-opencode-linux-arm64": "3.3.0",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.3.0",
|
||||
"oh-my-opencode-linux-x64": "3.3.0",
|
||||
"oh-my-opencode-linux-x64-musl": "3.3.0",
|
||||
"oh-my-opencode-windows-x64": "3.3.0",
|
||||
"oh-my-opencode-darwin-arm64": "3.3.1",
|
||||
"oh-my-opencode-darwin-x64": "3.3.1",
|
||||
"oh-my-opencode-linux-arm64": "3.3.1",
|
||||
"oh-my-opencode-linux-arm64-musl": "3.3.1",
|
||||
"oh-my-opencode-linux-x64": "3.3.1",
|
||||
"oh-my-opencode-linux-x64-musl": "3.3.1",
|
||||
"oh-my-opencode-windows-x64": "3.3.1",
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -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.3.0", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-P2kZKJqZaA4j0qtGM3I8+ZeH204ai27ni/OXLjtFdOewRjJgrahxaC1XslgK7q/KU9fXz6BQfEqAjbvyPf/rgQ=="],
|
||||
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.3.1", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-R+o42Km6bsIaW6D3I8uu2HCF3BjIWqa/fg38W5y4hJEOw4mL0Q7uV4R+0vtrXRHo9crXTK9ag0fqVQUm+Y6iAQ=="],
|
||||
|
||||
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.3.0", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-RopOorbW1WyhMQJ+ipuqiOA1GICS+3IkOwNyEe0KZlCLpoEDTyFopIL87HSns+gEQPMxnknroDp8lzxn1AKgjw=="],
|
||||
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.3.1", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-7VTbpR1vH3OEkoJxBKtYuxFPX8M3IbJKoeHWME9iK6FpT11W1ASsjyuhvzB1jcxSeqF8ddMnjitlG5ub6h5EVw=="],
|
||||
|
||||
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.3.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-297iEfuK+05g+q64crPW78Zbgm/j5PGjDDweSPkZ6rI6SEfHMvOIkGxMvN8gugM3zcH8FOCQXoY2nC8b6x3pwQ=="],
|
||||
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.3.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-BZ/r/CFlvbOxkdZZrRoT16xFOjibRZHuwQnaE4f0JvOzgK6/HWp3zJI1+2/aX/oK5GA6lZxNWRrJC/SKUi8LEg=="],
|
||||
|
||||
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.3.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-oVxP0+yn66HQYfrl9QT6I7TumRzciuPB4z24+PwKEVcDjPbWXQqLY1gwOGHZAQBPLf0vwewv9ybEDVD42RRH4g=="],
|
||||
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.3.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-U90Wruf21h+CJbtcrS7MeTAc/5VOF6RI+5jr7qj/cCxjXNJtjhyJdz/maehArjtgf304+lYCM/Mh1i+G2D3YFQ=="],
|
||||
|
||||
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.3.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-k9LoLkisLJwJNR1J0Bh1bjGtGBkl5D9WzFPSdZCAlyiT6TgG9w5erPTlXqtl2Lt0We5tYUVYlkEIHRMK/ugNsQ=="],
|
||||
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.3.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-sYzohSNdwsAhivbXcbhPdF1qqQi2CCI7FSgbmvvfBOMyZ8HAgqOFqYW2r3GPdmtywzkjOTvCzTG56FZwEjx15w=="],
|
||||
|
||||
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.3.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-7asXCeae7wBxJrzoZ7J6Yo1oaOxwUN3bTO7jWurCTMs5TDHO+pEHysgv/nuF1jvj1T+r1vg1H5ZmopuKy1qvXg=="],
|
||||
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.3.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-aG5pZ4eWS0YSGUicOnjMkUPrIqQV4poYF+d9SIvrfvlaMcK6WlQn7jXzgNCwJsfGn5lyhSmjshZBEU+v79Ua3w=="],
|
||||
|
||||
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.3.0", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-ABvwfaXb2xdrpbivzlPPJzIm5vXp+QlVakkaHEQf3TU6Mi/+fehH6Qhq/KMh66FDO2gq3xmxbH7nktHRQp9kNA=="],
|
||||
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.3.1", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-FGH7cnzBqNwjSkzCDglMsVttaq+MsykAxa7ehaFK+0dnBZArvllS3W13a3dGaANHMZzfK0vz8hNDUdVi7Z63cA=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
|
||||
@ -61,7 +61,7 @@ agents/
|
||||
|
||||
## HOW TO ADD
|
||||
1. Create `src/agents/my-agent.ts` exporting factory + metadata.
|
||||
2. Add to `agentSources` in `src/agents/utils.ts`.
|
||||
2. Add to `agentSources` in `src/agents/builtin-agents.ts`.
|
||||
3. Update `AgentNameSchema` in `src/config/schema.ts`.
|
||||
4. Register in `src/index.ts` initialization.
|
||||
|
||||
|
||||
52
src/agents/agent-builder.ts
Normal file
52
src/agents/agent-builder.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentFactory } from "./types"
|
||||
import type { CategoriesConfig, CategoryConfig, GitMasterConfig } from "../config/schema"
|
||||
import type { BrowserAutomationProvider } from "../config/schema"
|
||||
import { DEFAULT_CATEGORIES } from "../tools/delegate-task/constants"
|
||||
import { resolveMultipleSkills } from "../features/opencode-skill-loader/skill-content"
|
||||
|
||||
export type AgentSource = AgentFactory | AgentConfig
|
||||
|
||||
export function isFactory(source: AgentSource): source is AgentFactory {
|
||||
return typeof source === "function"
|
||||
}
|
||||
|
||||
export function buildAgent(
|
||||
source: AgentSource,
|
||||
model: string,
|
||||
categories?: CategoriesConfig,
|
||||
gitMasterConfig?: GitMasterConfig,
|
||||
browserProvider?: BrowserAutomationProvider,
|
||||
disabledSkills?: Set<string>
|
||||
): AgentConfig {
|
||||
const base = isFactory(source) ? source(model) : source
|
||||
const categoryConfigs: Record<string, CategoryConfig> = categories
|
||||
? { ...DEFAULT_CATEGORIES, ...categories }
|
||||
: DEFAULT_CATEGORIES
|
||||
|
||||
const agentWithCategory = base as AgentConfig & { category?: string; skills?: string[]; variant?: string }
|
||||
if (agentWithCategory.category) {
|
||||
const categoryConfig = categoryConfigs[agentWithCategory.category]
|
||||
if (categoryConfig) {
|
||||
if (!base.model) {
|
||||
base.model = categoryConfig.model
|
||||
}
|
||||
if (base.temperature === undefined && categoryConfig.temperature !== undefined) {
|
||||
base.temperature = categoryConfig.temperature
|
||||
}
|
||||
if (base.variant === undefined && categoryConfig.variant !== undefined) {
|
||||
base.variant = categoryConfig.variant
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (agentWithCategory.skills?.length) {
|
||||
const { resolved } = resolveMultipleSkills(agentWithCategory.skills, { gitMasterConfig, browserProvider, disabledSkills })
|
||||
if (resolved.size > 0) {
|
||||
const skillContent = Array.from(resolved.values()).join("\n\n")
|
||||
base.prompt = skillContent + (base.prompt ? "\n\n" + base.prompt : "")
|
||||
}
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
142
src/agents/atlas/agent.ts
Normal file
142
src/agents/atlas/agent.ts
Normal file
@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Atlas - Master Orchestrator Agent
|
||||
*
|
||||
* Orchestrates work via task() to complete ALL tasks in a todo list until fully done.
|
||||
* You are the conductor of a symphony of specialized agents.
|
||||
*
|
||||
* Routing:
|
||||
* 1. GPT models (openai/*, github-copilot/gpt-*) → gpt.ts (GPT-5.2 optimized)
|
||||
* 2. Default (Claude, etc.) → default.ts (Claude-optimized)
|
||||
*/
|
||||
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentMode, AgentPromptMetadata } from "../types"
|
||||
import { isGptModel } from "../types"
|
||||
import type { AvailableAgent, AvailableSkill, AvailableCategory } from "../dynamic-agent-prompt-builder"
|
||||
import { buildCategorySkillsDelegationGuide } from "../dynamic-agent-prompt-builder"
|
||||
import type { CategoryConfig } from "../../config/schema"
|
||||
import { DEFAULT_CATEGORIES } from "../../tools/delegate-task/constants"
|
||||
import { createAgentToolRestrictions } from "../../shared/permission-compat"
|
||||
|
||||
import { getDefaultAtlasPrompt } from "./default"
|
||||
import { getGptAtlasPrompt } from "./gpt"
|
||||
import {
|
||||
getCategoryDescription,
|
||||
buildAgentSelectionSection,
|
||||
buildCategorySection,
|
||||
buildSkillsSection,
|
||||
buildDecisionMatrix,
|
||||
} from "./prompt-section-builder"
|
||||
|
||||
const MODE: AgentMode = "primary"
|
||||
|
||||
export type AtlasPromptSource = "default" | "gpt"
|
||||
|
||||
/**
|
||||
* Determines which Atlas prompt to use based on model.
|
||||
*/
|
||||
export function getAtlasPromptSource(model?: string): AtlasPromptSource {
|
||||
if (model && isGptModel(model)) {
|
||||
return "gpt"
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
|
||||
export interface OrchestratorContext {
|
||||
model?: string
|
||||
availableAgents?: AvailableAgent[]
|
||||
availableSkills?: AvailableSkill[]
|
||||
userCategories?: Record<string, CategoryConfig>
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the appropriate Atlas prompt based on model.
|
||||
*/
|
||||
export function getAtlasPrompt(model?: string): string {
|
||||
const source = getAtlasPromptSource(model)
|
||||
|
||||
switch (source) {
|
||||
case "gpt":
|
||||
return getGptAtlasPrompt()
|
||||
case "default":
|
||||
default:
|
||||
return getDefaultAtlasPrompt()
|
||||
}
|
||||
}
|
||||
|
||||
function buildDynamicOrchestratorPrompt(ctx?: OrchestratorContext): string {
|
||||
const agents = ctx?.availableAgents ?? []
|
||||
const skills = ctx?.availableSkills ?? []
|
||||
const userCategories = ctx?.userCategories
|
||||
const model = ctx?.model
|
||||
|
||||
const allCategories = { ...DEFAULT_CATEGORIES, ...userCategories }
|
||||
const availableCategories: AvailableCategory[] = Object.entries(allCategories).map(([name]) => ({
|
||||
name,
|
||||
description: getCategoryDescription(name, userCategories),
|
||||
}))
|
||||
|
||||
const categorySection = buildCategorySection(userCategories)
|
||||
const agentSection = buildAgentSelectionSection(agents)
|
||||
const decisionMatrix = buildDecisionMatrix(agents, userCategories)
|
||||
const skillsSection = buildSkillsSection(skills)
|
||||
const categorySkillsGuide = buildCategorySkillsDelegationGuide(availableCategories, skills)
|
||||
|
||||
const basePrompt = getAtlasPrompt(model)
|
||||
|
||||
return basePrompt
|
||||
.replace("{CATEGORY_SECTION}", categorySection)
|
||||
.replace("{AGENT_SECTION}", agentSection)
|
||||
.replace("{DECISION_MATRIX}", decisionMatrix)
|
||||
.replace("{SKILLS_SECTION}", skillsSection)
|
||||
.replace("{{CATEGORY_SKILLS_DELEGATION_GUIDE}}", categorySkillsGuide)
|
||||
}
|
||||
|
||||
export function createAtlasAgent(ctx: OrchestratorContext): AgentConfig {
|
||||
const restrictions = createAgentToolRestrictions([
|
||||
"task",
|
||||
"call_omo_agent",
|
||||
])
|
||||
|
||||
const baseConfig = {
|
||||
description:
|
||||
"Orchestrates work via task() to complete ALL tasks in a todo list until fully done. (Atlas - OhMyOpenCode)",
|
||||
mode: MODE,
|
||||
...(ctx.model ? { model: ctx.model } : {}),
|
||||
temperature: 0.1,
|
||||
prompt: buildDynamicOrchestratorPrompt(ctx),
|
||||
color: "#10B981",
|
||||
...restrictions,
|
||||
}
|
||||
|
||||
return baseConfig as AgentConfig
|
||||
}
|
||||
createAtlasAgent.mode = MODE
|
||||
|
||||
export const atlasPromptMetadata: AgentPromptMetadata = {
|
||||
category: "advisor",
|
||||
cost: "EXPENSIVE",
|
||||
promptAlias: "Atlas",
|
||||
triggers: [
|
||||
{
|
||||
domain: "Todo list orchestration",
|
||||
trigger: "Complete ALL tasks in a todo list with verification",
|
||||
},
|
||||
{
|
||||
domain: "Multi-agent coordination",
|
||||
trigger: "Parallel task execution across specialized agents",
|
||||
},
|
||||
],
|
||||
useWhen: [
|
||||
"User provides a todo list path (.sisyphus/plans/{name}.md)",
|
||||
"Multiple tasks need to be completed in sequence or parallel",
|
||||
"Work requires coordination across multiple specialized agents",
|
||||
],
|
||||
avoidWhen: [
|
||||
"Single simple task that doesn't require orchestration",
|
||||
"Tasks that can be handled directly by one agent",
|
||||
"When user wants to execute tasks manually",
|
||||
],
|
||||
keyTrigger:
|
||||
"Todo list path provided OR multiple tasks requiring multi-agent orchestration",
|
||||
}
|
||||
@ -1,33 +1,3 @@
|
||||
/**
|
||||
* Atlas - Master Orchestrator Agent
|
||||
*
|
||||
* Orchestrates work via task() to complete ALL tasks in a todo list until fully done.
|
||||
* You are the conductor of a symphony of specialized agents.
|
||||
*
|
||||
* Routing:
|
||||
* 1. GPT models (openai/*, github-copilot/gpt-*) → gpt.ts (GPT-5.2 optimized)
|
||||
* 2. Default (Claude, etc.) → default.ts (Claude-optimized)
|
||||
*/
|
||||
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentMode, AgentPromptMetadata } from "../types"
|
||||
import { isGptModel } from "../types"
|
||||
import type { AvailableAgent, AvailableSkill, AvailableCategory } from "../dynamic-agent-prompt-builder"
|
||||
import { buildCategorySkillsDelegationGuide } from "../dynamic-agent-prompt-builder"
|
||||
import type { CategoryConfig } from "../../config/schema"
|
||||
import { DEFAULT_CATEGORIES } from "../../tools/delegate-task/constants"
|
||||
import { createAgentToolRestrictions } from "../../shared/permission-compat"
|
||||
|
||||
import { ATLAS_SYSTEM_PROMPT, getDefaultAtlasPrompt } from "./default"
|
||||
import { ATLAS_GPT_SYSTEM_PROMPT, getGptAtlasPrompt } from "./gpt"
|
||||
import {
|
||||
getCategoryDescription,
|
||||
buildAgentSelectionSection,
|
||||
buildCategorySection,
|
||||
buildSkillsSection,
|
||||
buildDecisionMatrix,
|
||||
} from "./utils"
|
||||
|
||||
export { ATLAS_SYSTEM_PROMPT, getDefaultAtlasPrompt } from "./default"
|
||||
export { ATLAS_GPT_SYSTEM_PROMPT, getGptAtlasPrompt } from "./gpt"
|
||||
export {
|
||||
@ -36,118 +6,9 @@ export {
|
||||
buildCategorySection,
|
||||
buildSkillsSection,
|
||||
buildDecisionMatrix,
|
||||
} from "./utils"
|
||||
export { isGptModel }
|
||||
} from "./prompt-section-builder"
|
||||
|
||||
const MODE: AgentMode = "primary"
|
||||
export { createAtlasAgent, getAtlasPromptSource, getAtlasPrompt, atlasPromptMetadata } from "./agent"
|
||||
export type { AtlasPromptSource, OrchestratorContext } from "./agent"
|
||||
|
||||
export type AtlasPromptSource = "default" | "gpt"
|
||||
|
||||
/**
|
||||
* Determines which Atlas prompt to use based on model.
|
||||
*/
|
||||
export function getAtlasPromptSource(model?: string): AtlasPromptSource {
|
||||
if (model && isGptModel(model)) {
|
||||
return "gpt"
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
|
||||
export interface OrchestratorContext {
|
||||
model?: string
|
||||
availableAgents?: AvailableAgent[]
|
||||
availableSkills?: AvailableSkill[]
|
||||
userCategories?: Record<string, CategoryConfig>
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the appropriate Atlas prompt based on model.
|
||||
*/
|
||||
export function getAtlasPrompt(model?: string): string {
|
||||
const source = getAtlasPromptSource(model)
|
||||
|
||||
switch (source) {
|
||||
case "gpt":
|
||||
return getGptAtlasPrompt()
|
||||
case "default":
|
||||
default:
|
||||
return getDefaultAtlasPrompt()
|
||||
}
|
||||
}
|
||||
|
||||
function buildDynamicOrchestratorPrompt(ctx?: OrchestratorContext): string {
|
||||
const agents = ctx?.availableAgents ?? []
|
||||
const skills = ctx?.availableSkills ?? []
|
||||
const userCategories = ctx?.userCategories
|
||||
const model = ctx?.model
|
||||
|
||||
const allCategories = { ...DEFAULT_CATEGORIES, ...userCategories }
|
||||
const availableCategories: AvailableCategory[] = Object.entries(allCategories).map(([name]) => ({
|
||||
name,
|
||||
description: getCategoryDescription(name, userCategories),
|
||||
}))
|
||||
|
||||
const categorySection = buildCategorySection(userCategories)
|
||||
const agentSection = buildAgentSelectionSection(agents)
|
||||
const decisionMatrix = buildDecisionMatrix(agents, userCategories)
|
||||
const skillsSection = buildSkillsSection(skills)
|
||||
const categorySkillsGuide = buildCategorySkillsDelegationGuide(availableCategories, skills)
|
||||
|
||||
const basePrompt = getAtlasPrompt(model)
|
||||
|
||||
return basePrompt
|
||||
.replace("{CATEGORY_SECTION}", categorySection)
|
||||
.replace("{AGENT_SECTION}", agentSection)
|
||||
.replace("{DECISION_MATRIX}", decisionMatrix)
|
||||
.replace("{SKILLS_SECTION}", skillsSection)
|
||||
.replace("{{CATEGORY_SKILLS_DELEGATION_GUIDE}}", categorySkillsGuide)
|
||||
}
|
||||
|
||||
export function createAtlasAgent(ctx: OrchestratorContext): AgentConfig {
|
||||
const restrictions = createAgentToolRestrictions([
|
||||
"task",
|
||||
"call_omo_agent",
|
||||
])
|
||||
|
||||
const baseConfig = {
|
||||
description:
|
||||
"Orchestrates work via task() to complete ALL tasks in a todo list until fully done. (Atlas - OhMyOpenCode)",
|
||||
mode: MODE,
|
||||
...(ctx.model ? { model: ctx.model } : {}),
|
||||
temperature: 0.1,
|
||||
prompt: buildDynamicOrchestratorPrompt(ctx),
|
||||
color: "#10B981",
|
||||
...restrictions,
|
||||
}
|
||||
|
||||
return baseConfig as AgentConfig
|
||||
}
|
||||
createAtlasAgent.mode = MODE
|
||||
|
||||
export const atlasPromptMetadata: AgentPromptMetadata = {
|
||||
category: "advisor",
|
||||
cost: "EXPENSIVE",
|
||||
promptAlias: "Atlas",
|
||||
triggers: [
|
||||
{
|
||||
domain: "Todo list orchestration",
|
||||
trigger: "Complete ALL tasks in a todo list with verification",
|
||||
},
|
||||
{
|
||||
domain: "Multi-agent coordination",
|
||||
trigger: "Parallel task execution across specialized agents",
|
||||
},
|
||||
],
|
||||
useWhen: [
|
||||
"User provides a todo list path (.sisyphus/plans/{name}.md)",
|
||||
"Multiple tasks need to be completed in sequence or parallel",
|
||||
"Work requires coordination across multiple specialized agents",
|
||||
],
|
||||
avoidWhen: [
|
||||
"Single simple task that doesn't require orchestration",
|
||||
"Tasks that can be handled directly by one agent",
|
||||
"When user wants to execute tasks manually",
|
||||
],
|
||||
keyTrigger:
|
||||
"Todo list path provided OR multiple tasks requiring multi-agent orchestration",
|
||||
}
|
||||
export { isGptModel } from "../types"
|
||||
|
||||
163
src/agents/builtin-agents.ts
Normal file
163
src/agents/builtin-agents.ts
Normal file
@ -0,0 +1,163 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { BuiltinAgentName, AgentOverrides, AgentFactory, AgentPromptMetadata } from "./types"
|
||||
import type { CategoriesConfig, GitMasterConfig } from "../config/schema"
|
||||
import type { LoadedSkill } from "../features/opencode-skill-loader/types"
|
||||
import type { BrowserAutomationProvider } from "../config/schema"
|
||||
import { createSisyphusAgent } from "./sisyphus"
|
||||
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, metisPromptMetadata } from "./metis"
|
||||
import { createAtlasAgent, atlasPromptMetadata } from "./atlas"
|
||||
import { createMomusAgent, momusPromptMetadata } from "./momus"
|
||||
import { createHephaestusAgent } from "./hephaestus"
|
||||
import type { AvailableCategory } from "./dynamic-agent-prompt-builder"
|
||||
import { fetchAvailableModels, readConnectedProvidersCache } from "../shared"
|
||||
import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants"
|
||||
import { buildAvailableSkills } from "./builtin-agents/available-skills"
|
||||
import { collectPendingBuiltinAgents } from "./builtin-agents/general-agents"
|
||||
import { maybeCreateSisyphusConfig } from "./builtin-agents/sisyphus-agent"
|
||||
import { maybeCreateHephaestusConfig } from "./builtin-agents/hephaestus-agent"
|
||||
import { maybeCreateAtlasConfig } from "./builtin-agents/atlas-agent"
|
||||
|
||||
type AgentSource = AgentFactory | AgentConfig
|
||||
|
||||
const agentSources: Record<BuiltinAgentName, AgentSource> = {
|
||||
sisyphus: createSisyphusAgent,
|
||||
hephaestus: createHephaestusAgent,
|
||||
oracle: createOracleAgent,
|
||||
librarian: createLibrarianAgent,
|
||||
explore: createExploreAgent,
|
||||
"multimodal-looker": createMultimodalLookerAgent,
|
||||
metis: createMetisAgent,
|
||||
momus: createMomusAgent,
|
||||
// Note: Atlas is handled specially in createBuiltinAgents()
|
||||
// because it needs OrchestratorContext, not just a model string
|
||||
atlas: createAtlasAgent as AgentFactory,
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata for each agent, used to build Sisyphus's dynamic prompt sections
|
||||
* (Delegation Table, Tool Selection, Key Triggers, etc.)
|
||||
*/
|
||||
const agentMetadata: Partial<Record<BuiltinAgentName, AgentPromptMetadata>> = {
|
||||
oracle: ORACLE_PROMPT_METADATA,
|
||||
librarian: LIBRARIAN_PROMPT_METADATA,
|
||||
explore: EXPLORE_PROMPT_METADATA,
|
||||
"multimodal-looker": MULTIMODAL_LOOKER_PROMPT_METADATA,
|
||||
metis: metisPromptMetadata,
|
||||
momus: momusPromptMetadata,
|
||||
atlas: atlasPromptMetadata,
|
||||
}
|
||||
|
||||
export async function createBuiltinAgents(
|
||||
disabledAgents: string[] = [],
|
||||
agentOverrides: AgentOverrides = {},
|
||||
directory?: string,
|
||||
systemDefaultModel?: string,
|
||||
categories?: CategoriesConfig,
|
||||
gitMasterConfig?: GitMasterConfig,
|
||||
discoveredSkills: LoadedSkill[] = [],
|
||||
client?: any,
|
||||
browserProvider?: BrowserAutomationProvider,
|
||||
uiSelectedModel?: string,
|
||||
disabledSkills?: Set<string>
|
||||
): Promise<Record<string, AgentConfig>> {
|
||||
void client
|
||||
|
||||
const connectedProviders = readConnectedProvidersCache()
|
||||
// IMPORTANT: Do NOT pass client to fetchAvailableModels during plugin initialization.
|
||||
// This function is called from config handler, and calling client API causes deadlock.
|
||||
// See: https://github.com/code-yeongyu/oh-my-opencode/issues/1301
|
||||
const availableModels = await fetchAvailableModels(undefined, {
|
||||
connectedProviders: connectedProviders ?? undefined,
|
||||
})
|
||||
const isFirstRunNoCache =
|
||||
availableModels.size === 0 && (!connectedProviders || connectedProviders.length === 0)
|
||||
|
||||
const result: Record<string, AgentConfig> = {}
|
||||
|
||||
const mergedCategories = categories
|
||||
? { ...DEFAULT_CATEGORIES, ...categories }
|
||||
: DEFAULT_CATEGORIES
|
||||
|
||||
const availableCategories: AvailableCategory[] = Object.entries(mergedCategories).map(([name]) => ({
|
||||
name,
|
||||
description: categories?.[name]?.description ?? CATEGORY_DESCRIPTIONS[name] ?? "General tasks",
|
||||
}))
|
||||
|
||||
const availableSkills = buildAvailableSkills(discoveredSkills, browserProvider, disabledSkills)
|
||||
|
||||
// Collect general agents first (for availableAgents), but don't add to result yet
|
||||
const { pendingAgentConfigs, availableAgents } = collectPendingBuiltinAgents({
|
||||
agentSources,
|
||||
agentMetadata,
|
||||
disabledAgents,
|
||||
agentOverrides,
|
||||
directory,
|
||||
systemDefaultModel,
|
||||
mergedCategories,
|
||||
gitMasterConfig,
|
||||
browserProvider,
|
||||
uiSelectedModel,
|
||||
availableModels,
|
||||
disabledSkills,
|
||||
})
|
||||
|
||||
const sisyphusConfig = maybeCreateSisyphusConfig({
|
||||
disabledAgents,
|
||||
agentOverrides,
|
||||
uiSelectedModel,
|
||||
availableModels,
|
||||
systemDefaultModel,
|
||||
isFirstRunNoCache,
|
||||
availableAgents,
|
||||
availableSkills,
|
||||
availableCategories,
|
||||
mergedCategories,
|
||||
directory,
|
||||
userCategories: categories,
|
||||
})
|
||||
if (sisyphusConfig) {
|
||||
result["sisyphus"] = sisyphusConfig
|
||||
}
|
||||
|
||||
const hephaestusConfig = maybeCreateHephaestusConfig({
|
||||
disabledAgents,
|
||||
agentOverrides,
|
||||
availableModels,
|
||||
systemDefaultModel,
|
||||
isFirstRunNoCache,
|
||||
availableAgents,
|
||||
availableSkills,
|
||||
availableCategories,
|
||||
mergedCategories,
|
||||
directory,
|
||||
})
|
||||
if (hephaestusConfig) {
|
||||
result["hephaestus"] = hephaestusConfig
|
||||
}
|
||||
|
||||
// Add pending agents after sisyphus and hephaestus to maintain order
|
||||
for (const [name, config] of pendingAgentConfigs) {
|
||||
result[name] = config
|
||||
}
|
||||
|
||||
const atlasConfig = maybeCreateAtlasConfig({
|
||||
disabledAgents,
|
||||
agentOverrides,
|
||||
uiSelectedModel,
|
||||
availableModels,
|
||||
systemDefaultModel,
|
||||
availableAgents,
|
||||
availableSkills,
|
||||
mergedCategories,
|
||||
userCategories: categories,
|
||||
})
|
||||
if (atlasConfig) {
|
||||
result["atlas"] = atlasConfig
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
61
src/agents/builtin-agents/agent-overrides.ts
Normal file
61
src/agents/builtin-agents/agent-overrides.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentOverrideConfig } from "../types"
|
||||
import type { CategoryConfig } from "../../config/schema"
|
||||
import { deepMerge, migrateAgentConfig } from "../../shared"
|
||||
|
||||
/**
|
||||
* Expands a category reference from an agent override into concrete config properties.
|
||||
* Category properties are applied unconditionally (overwriting factory defaults),
|
||||
* because the user's chosen category should take priority over factory base values.
|
||||
* Direct override properties applied later via mergeAgentConfig() will supersede these.
|
||||
*/
|
||||
export function applyCategoryOverride(
|
||||
config: AgentConfig,
|
||||
categoryName: string,
|
||||
mergedCategories: Record<string, CategoryConfig>
|
||||
): AgentConfig {
|
||||
const categoryConfig = mergedCategories[categoryName]
|
||||
if (!categoryConfig) return config
|
||||
|
||||
const result = { ...config } as AgentConfig & Record<string, unknown>
|
||||
if (categoryConfig.model) result.model = categoryConfig.model
|
||||
if (categoryConfig.variant !== undefined) result.variant = categoryConfig.variant
|
||||
if (categoryConfig.temperature !== undefined) result.temperature = categoryConfig.temperature
|
||||
if (categoryConfig.reasoningEffort !== undefined) result.reasoningEffort = categoryConfig.reasoningEffort
|
||||
if (categoryConfig.textVerbosity !== undefined) result.textVerbosity = categoryConfig.textVerbosity
|
||||
if (categoryConfig.thinking !== undefined) result.thinking = categoryConfig.thinking
|
||||
if (categoryConfig.top_p !== undefined) result.top_p = categoryConfig.top_p
|
||||
if (categoryConfig.maxTokens !== undefined) result.maxTokens = categoryConfig.maxTokens
|
||||
|
||||
return result as AgentConfig
|
||||
}
|
||||
|
||||
export function mergeAgentConfig(base: AgentConfig, override: AgentOverrideConfig): AgentConfig {
|
||||
const migratedOverride = migrateAgentConfig(override as Record<string, unknown>) as AgentOverrideConfig
|
||||
const { prompt_append, ...rest } = migratedOverride
|
||||
const merged = deepMerge(base, rest as Partial<AgentConfig>)
|
||||
|
||||
if (prompt_append && merged.prompt) {
|
||||
merged.prompt = merged.prompt + "\n" + prompt_append
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
63
src/agents/builtin-agents/atlas-agent.ts
Normal file
63
src/agents/builtin-agents/atlas-agent.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentOverrides } from "../types"
|
||||
import type { CategoriesConfig, CategoryConfig } from "../../config/schema"
|
||||
import type { AvailableAgent, AvailableSkill } from "../dynamic-agent-prompt-builder"
|
||||
import { AGENT_MODEL_REQUIREMENTS } from "../../shared"
|
||||
import { applyOverrides } from "./agent-overrides"
|
||||
import { applyModelResolution } from "./model-resolution"
|
||||
import { createAtlasAgent } from "../atlas"
|
||||
|
||||
export function maybeCreateAtlasConfig(input: {
|
||||
disabledAgents: string[]
|
||||
agentOverrides: AgentOverrides
|
||||
uiSelectedModel?: string
|
||||
availableModels: Set<string>
|
||||
systemDefaultModel?: string
|
||||
availableAgents: AvailableAgent[]
|
||||
availableSkills: AvailableSkill[]
|
||||
mergedCategories: Record<string, CategoryConfig>
|
||||
userCategories?: CategoriesConfig
|
||||
}): AgentConfig | undefined {
|
||||
const {
|
||||
disabledAgents,
|
||||
agentOverrides,
|
||||
uiSelectedModel,
|
||||
availableModels,
|
||||
systemDefaultModel,
|
||||
availableAgents,
|
||||
availableSkills,
|
||||
mergedCategories,
|
||||
userCategories,
|
||||
} = input
|
||||
|
||||
if (disabledAgents.includes("atlas")) return undefined
|
||||
|
||||
const orchestratorOverride = agentOverrides["atlas"]
|
||||
const atlasRequirement = AGENT_MODEL_REQUIREMENTS["atlas"]
|
||||
|
||||
const atlasResolution = applyModelResolution({
|
||||
uiSelectedModel: orchestratorOverride?.model ? undefined : uiSelectedModel,
|
||||
userModel: orchestratorOverride?.model,
|
||||
requirement: atlasRequirement,
|
||||
availableModels,
|
||||
systemDefaultModel,
|
||||
})
|
||||
|
||||
if (!atlasResolution) return undefined
|
||||
const { model: atlasModel, variant: atlasResolvedVariant } = atlasResolution
|
||||
|
||||
let orchestratorConfig = createAtlasAgent({
|
||||
model: atlasModel,
|
||||
availableAgents,
|
||||
availableSkills,
|
||||
userCategories,
|
||||
})
|
||||
|
||||
if (atlasResolvedVariant) {
|
||||
orchestratorConfig = { ...orchestratorConfig, variant: atlasResolvedVariant }
|
||||
}
|
||||
|
||||
orchestratorConfig = applyOverrides(orchestratorConfig, orchestratorOverride, mergedCategories)
|
||||
|
||||
return orchestratorConfig
|
||||
}
|
||||
35
src/agents/builtin-agents/available-skills.ts
Normal file
35
src/agents/builtin-agents/available-skills.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import type { AvailableSkill } from "../dynamic-agent-prompt-builder"
|
||||
import type { BrowserAutomationProvider } from "../../config/schema"
|
||||
import type { LoadedSkill, SkillScope } from "../../features/opencode-skill-loader/types"
|
||||
import { createBuiltinSkills } from "../../features/builtin-skills"
|
||||
|
||||
function mapScopeToLocation(scope: SkillScope): AvailableSkill["location"] {
|
||||
if (scope === "user" || scope === "opencode") return "user"
|
||||
if (scope === "project" || scope === "opencode-project") return "project"
|
||||
return "plugin"
|
||||
}
|
||||
|
||||
export function buildAvailableSkills(
|
||||
discoveredSkills: LoadedSkill[],
|
||||
browserProvider?: BrowserAutomationProvider,
|
||||
disabledSkills?: Set<string>
|
||||
): AvailableSkill[] {
|
||||
const builtinSkills = createBuiltinSkills({ browserProvider, disabledSkills })
|
||||
const builtinSkillNames = new Set(builtinSkills.map(s => s.name))
|
||||
|
||||
const builtinAvailable: AvailableSkill[] = builtinSkills.map((skill) => ({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
location: "plugin" as const,
|
||||
}))
|
||||
|
||||
const discoveredAvailable: AvailableSkill[] = discoveredSkills
|
||||
.filter(s => !builtinSkillNames.has(s.name))
|
||||
.map((skill) => ({
|
||||
name: skill.name,
|
||||
description: skill.definition.description ?? "",
|
||||
location: mapScopeToLocation(skill.scope),
|
||||
}))
|
||||
|
||||
return [...builtinAvailable, ...discoveredAvailable]
|
||||
}
|
||||
8
src/agents/builtin-agents/environment-context.ts
Normal file
8
src/agents/builtin-agents/environment-context.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import { createEnvContext } from "../env-context"
|
||||
|
||||
export function applyEnvironmentContext(config: AgentConfig, directory?: string): AgentConfig {
|
||||
if (!directory || !config.prompt) return config
|
||||
const envContext = createEnvContext()
|
||||
return { ...config, prompt: config.prompt + envContext }
|
||||
}
|
||||
108
src/agents/builtin-agents/general-agents.ts
Normal file
108
src/agents/builtin-agents/general-agents.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { BuiltinAgentName, AgentOverrides, AgentPromptMetadata } from "../types"
|
||||
import type { CategoryConfig, GitMasterConfig } from "../../config/schema"
|
||||
import type { BrowserAutomationProvider } from "../../config/schema"
|
||||
import type { AvailableAgent } from "../dynamic-agent-prompt-builder"
|
||||
import { AGENT_MODEL_REQUIREMENTS, isModelAvailable } from "../../shared"
|
||||
import { buildAgent, isFactory } from "../agent-builder"
|
||||
import { applyCategoryOverride, applyOverrides } from "./agent-overrides"
|
||||
import { applyEnvironmentContext } from "./environment-context"
|
||||
import { applyModelResolution } from "./model-resolution"
|
||||
|
||||
export function collectPendingBuiltinAgents(input: {
|
||||
agentSources: Record<BuiltinAgentName, import("../agent-builder").AgentSource>
|
||||
agentMetadata: Partial<Record<BuiltinAgentName, AgentPromptMetadata>>
|
||||
disabledAgents: string[]
|
||||
agentOverrides: AgentOverrides
|
||||
directory?: string
|
||||
systemDefaultModel?: string
|
||||
mergedCategories: Record<string, CategoryConfig>
|
||||
gitMasterConfig?: GitMasterConfig
|
||||
browserProvider?: BrowserAutomationProvider
|
||||
uiSelectedModel?: string
|
||||
availableModels: Set<string>
|
||||
disabledSkills?: Set<string>
|
||||
}): { pendingAgentConfigs: Map<string, AgentConfig>; availableAgents: AvailableAgent[] } {
|
||||
const {
|
||||
agentSources,
|
||||
agentMetadata,
|
||||
disabledAgents,
|
||||
agentOverrides,
|
||||
directory,
|
||||
systemDefaultModel,
|
||||
mergedCategories,
|
||||
gitMasterConfig,
|
||||
browserProvider,
|
||||
uiSelectedModel,
|
||||
availableModels,
|
||||
disabledSkills,
|
||||
} = input
|
||||
|
||||
const availableAgents: AvailableAgent[] = []
|
||||
const pendingAgentConfigs: Map<string, AgentConfig> = new Map()
|
||||
|
||||
for (const [name, source] of Object.entries(agentSources)) {
|
||||
const agentName = name as BuiltinAgentName
|
||||
|
||||
if (agentName === "sisyphus") continue
|
||||
if (agentName === "hephaestus") continue
|
||||
if (agentName === "atlas") continue
|
||||
if (disabledAgents.some((name) => name.toLowerCase() === agentName.toLowerCase())) continue
|
||||
|
||||
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
|
||||
if (requirement?.requiresModel && availableModels) {
|
||||
if (!isModelAvailable(requirement.requiresModel, availableModels)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const isPrimaryAgent = isFactory(source) && source.mode === "primary"
|
||||
|
||||
const resolution = applyModelResolution({
|
||||
uiSelectedModel: (isPrimaryAgent && !override?.model) ? uiSelectedModel : undefined,
|
||||
userModel: override?.model,
|
||||
requirement,
|
||||
availableModels,
|
||||
systemDefaultModel,
|
||||
})
|
||||
if (!resolution) continue
|
||||
const { model, variant: resolvedVariant } = resolution
|
||||
|
||||
let config = buildAgent(source, model, mergedCategories, gitMasterConfig, browserProvider, disabledSkills)
|
||||
|
||||
// Apply resolved variant from model fallback chain
|
||||
if (resolvedVariant) {
|
||||
config = { ...config, variant: resolvedVariant }
|
||||
}
|
||||
|
||||
// Expand override.category into concrete properties (higher priority than factory/resolved)
|
||||
const overrideCategory = (override as Record<string, unknown> | undefined)?.category as string | undefined
|
||||
if (overrideCategory) {
|
||||
config = applyCategoryOverride(config, overrideCategory, mergedCategories)
|
||||
}
|
||||
|
||||
if (agentName === "librarian") {
|
||||
config = applyEnvironmentContext(config, directory)
|
||||
}
|
||||
|
||||
config = applyOverrides(config, override, mergedCategories)
|
||||
|
||||
// Store for later - will be added after sisyphus and hephaestus
|
||||
pendingAgentConfigs.set(name, config)
|
||||
|
||||
const metadata = agentMetadata[agentName]
|
||||
if (metadata) {
|
||||
availableAgents.push({
|
||||
name: agentName,
|
||||
description: config.description ?? "",
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return { pendingAgentConfigs, availableAgents }
|
||||
}
|
||||
88
src/agents/builtin-agents/hephaestus-agent.ts
Normal file
88
src/agents/builtin-agents/hephaestus-agent.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentOverrides } from "../types"
|
||||
import type { CategoryConfig } from "../../config/schema"
|
||||
import type { AvailableAgent, AvailableCategory, AvailableSkill } from "../dynamic-agent-prompt-builder"
|
||||
import { AGENT_MODEL_REQUIREMENTS, isAnyProviderConnected } from "../../shared"
|
||||
import { createHephaestusAgent } from "../hephaestus"
|
||||
import { createEnvContext } from "../env-context"
|
||||
import { applyCategoryOverride, mergeAgentConfig } from "./agent-overrides"
|
||||
import { applyModelResolution, getFirstFallbackModel } from "./model-resolution"
|
||||
|
||||
export function maybeCreateHephaestusConfig(input: {
|
||||
disabledAgents: string[]
|
||||
agentOverrides: AgentOverrides
|
||||
availableModels: Set<string>
|
||||
systemDefaultModel?: string
|
||||
isFirstRunNoCache: boolean
|
||||
availableAgents: AvailableAgent[]
|
||||
availableSkills: AvailableSkill[]
|
||||
availableCategories: AvailableCategory[]
|
||||
mergedCategories: Record<string, CategoryConfig>
|
||||
directory?: string
|
||||
}): AgentConfig | undefined {
|
||||
const {
|
||||
disabledAgents,
|
||||
agentOverrides,
|
||||
availableModels,
|
||||
systemDefaultModel,
|
||||
isFirstRunNoCache,
|
||||
availableAgents,
|
||||
availableSkills,
|
||||
availableCategories,
|
||||
mergedCategories,
|
||||
directory,
|
||||
} = input
|
||||
|
||||
if (disabledAgents.includes("hephaestus")) return undefined
|
||||
|
||||
const hephaestusOverride = agentOverrides["hephaestus"]
|
||||
const hephaestusRequirement = AGENT_MODEL_REQUIREMENTS["hephaestus"]
|
||||
const hasHephaestusExplicitConfig = hephaestusOverride !== undefined
|
||||
|
||||
const hasRequiredProvider =
|
||||
!hephaestusRequirement?.requiresProvider ||
|
||||
hasHephaestusExplicitConfig ||
|
||||
isFirstRunNoCache ||
|
||||
isAnyProviderConnected(hephaestusRequirement.requiresProvider, availableModels)
|
||||
|
||||
if (!hasRequiredProvider) return undefined
|
||||
|
||||
let hephaestusResolution = applyModelResolution({
|
||||
userModel: hephaestusOverride?.model,
|
||||
requirement: hephaestusRequirement,
|
||||
availableModels,
|
||||
systemDefaultModel,
|
||||
})
|
||||
|
||||
if (isFirstRunNoCache && !hephaestusOverride?.model) {
|
||||
hephaestusResolution = getFirstFallbackModel(hephaestusRequirement)
|
||||
}
|
||||
|
||||
if (!hephaestusResolution) return undefined
|
||||
const { model: hephaestusModel, variant: hephaestusResolvedVariant } = hephaestusResolution
|
||||
|
||||
let hephaestusConfig = createHephaestusAgent(
|
||||
hephaestusModel,
|
||||
availableAgents,
|
||||
undefined,
|
||||
availableSkills,
|
||||
availableCategories
|
||||
)
|
||||
|
||||
hephaestusConfig = { ...hephaestusConfig, variant: hephaestusResolvedVariant ?? "medium" }
|
||||
|
||||
const hepOverrideCategory = (hephaestusOverride as Record<string, unknown> | undefined)?.category as string | undefined
|
||||
if (hepOverrideCategory) {
|
||||
hephaestusConfig = applyCategoryOverride(hephaestusConfig, hepOverrideCategory, mergedCategories)
|
||||
}
|
||||
|
||||
if (directory && hephaestusConfig.prompt) {
|
||||
const envContext = createEnvContext()
|
||||
hephaestusConfig = { ...hephaestusConfig, prompt: hephaestusConfig.prompt + envContext }
|
||||
}
|
||||
|
||||
if (hephaestusOverride) {
|
||||
hephaestusConfig = mergeAgentConfig(hephaestusConfig, hephaestusOverride)
|
||||
}
|
||||
return hephaestusConfig
|
||||
}
|
||||
28
src/agents/builtin-agents/model-resolution.ts
Normal file
28
src/agents/builtin-agents/model-resolution.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { resolveModelPipeline } from "../../shared"
|
||||
|
||||
export 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 },
|
||||
})
|
||||
}
|
||||
|
||||
export function getFirstFallbackModel(requirement?: {
|
||||
fallbackChain?: { providers: string[]; model: string; variant?: string }[]
|
||||
}) {
|
||||
const entry = requirement?.fallbackChain?.[0]
|
||||
if (!entry || entry.providers.length === 0) return undefined
|
||||
return {
|
||||
model: `${entry.providers[0]}/${entry.model}`,
|
||||
provenance: "provider-fallback" as const,
|
||||
variant: entry.variant,
|
||||
}
|
||||
}
|
||||
81
src/agents/builtin-agents/sisyphus-agent.ts
Normal file
81
src/agents/builtin-agents/sisyphus-agent.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentOverrides } from "../types"
|
||||
import type { CategoriesConfig, CategoryConfig } from "../../config/schema"
|
||||
import type { AvailableAgent, AvailableCategory, AvailableSkill } from "../dynamic-agent-prompt-builder"
|
||||
import { AGENT_MODEL_REQUIREMENTS, isAnyFallbackModelAvailable } from "../../shared"
|
||||
import { applyEnvironmentContext } from "./environment-context"
|
||||
import { applyOverrides } from "./agent-overrides"
|
||||
import { applyModelResolution, getFirstFallbackModel } from "./model-resolution"
|
||||
import { createSisyphusAgent } from "../sisyphus"
|
||||
|
||||
export function maybeCreateSisyphusConfig(input: {
|
||||
disabledAgents: string[]
|
||||
agentOverrides: AgentOverrides
|
||||
uiSelectedModel?: string
|
||||
availableModels: Set<string>
|
||||
systemDefaultModel?: string
|
||||
isFirstRunNoCache: boolean
|
||||
availableAgents: AvailableAgent[]
|
||||
availableSkills: AvailableSkill[]
|
||||
availableCategories: AvailableCategory[]
|
||||
mergedCategories: Record<string, CategoryConfig>
|
||||
directory?: string
|
||||
userCategories?: CategoriesConfig
|
||||
}): AgentConfig | undefined {
|
||||
const {
|
||||
disabledAgents,
|
||||
agentOverrides,
|
||||
uiSelectedModel,
|
||||
availableModels,
|
||||
systemDefaultModel,
|
||||
isFirstRunNoCache,
|
||||
availableAgents,
|
||||
availableSkills,
|
||||
availableCategories,
|
||||
mergedCategories,
|
||||
directory,
|
||||
} = input
|
||||
|
||||
const sisyphusOverride = agentOverrides["sisyphus"]
|
||||
const sisyphusRequirement = AGENT_MODEL_REQUIREMENTS["sisyphus"]
|
||||
const hasSisyphusExplicitConfig = sisyphusOverride !== undefined
|
||||
const meetsSisyphusAnyModelRequirement =
|
||||
!sisyphusRequirement?.requiresAnyModel ||
|
||||
hasSisyphusExplicitConfig ||
|
||||
isFirstRunNoCache ||
|
||||
isAnyFallbackModelAvailable(sisyphusRequirement.fallbackChain, availableModels)
|
||||
|
||||
if (disabledAgents.includes("sisyphus") || !meetsSisyphusAnyModelRequirement) return undefined
|
||||
|
||||
let sisyphusResolution = applyModelResolution({
|
||||
uiSelectedModel: sisyphusOverride?.model ? undefined : uiSelectedModel,
|
||||
userModel: sisyphusOverride?.model,
|
||||
requirement: sisyphusRequirement,
|
||||
availableModels,
|
||||
systemDefaultModel,
|
||||
})
|
||||
|
||||
if (isFirstRunNoCache && !sisyphusOverride?.model && !uiSelectedModel) {
|
||||
sisyphusResolution = getFirstFallbackModel(sisyphusRequirement)
|
||||
}
|
||||
|
||||
if (!sisyphusResolution) return undefined
|
||||
const { model: sisyphusModel, variant: sisyphusResolvedVariant } = sisyphusResolution
|
||||
|
||||
let sisyphusConfig = createSisyphusAgent(
|
||||
sisyphusModel,
|
||||
availableAgents,
|
||||
undefined,
|
||||
availableSkills,
|
||||
availableCategories
|
||||
)
|
||||
|
||||
if (sisyphusResolvedVariant) {
|
||||
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusResolvedVariant }
|
||||
}
|
||||
|
||||
sisyphusConfig = applyOverrides(sisyphusConfig, sisyphusOverride, mergedCategories)
|
||||
sisyphusConfig = applyEnvironmentContext(sisyphusConfig, directory)
|
||||
|
||||
return sisyphusConfig
|
||||
}
|
||||
33
src/agents/env-context.ts
Normal file
33
src/agents/env-context.ts
Normal file
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Creates OmO-specific environment context (time, timezone, locale).
|
||||
* Note: Working directory, platform, and date are already provided by OpenCode's system.ts,
|
||||
* so we only include fields that OpenCode doesn't provide to avoid duplication.
|
||||
* See: https://github.com/code-yeongyu/oh-my-opencode/issues/379
|
||||
*/
|
||||
export function createEnvContext(): string {
|
||||
const now = new Date()
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const locale = Intl.DateTimeFormat().resolvedOptions().locale
|
||||
|
||||
const dateStr = now.toLocaleDateString(locale, {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
|
||||
const timeStr = now.toLocaleTimeString(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: true,
|
||||
})
|
||||
|
||||
return `
|
||||
<omo-env>
|
||||
Current date: ${dateStr}
|
||||
Current time: ${timeStr}
|
||||
Timezone: ${timezone}
|
||||
Locale: ${locale}
|
||||
</omo-env>`
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
export * from "./types"
|
||||
export { createBuiltinAgents } from "./utils"
|
||||
export { createBuiltinAgents } from "./builtin-agents"
|
||||
export type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder"
|
||||
export { createSisyphusAgent } from "./sisyphus"
|
||||
export { createOracleAgent, ORACLE_PROMPT_METADATA } from "./oracle"
|
||||
|
||||
119
src/agents/sisyphus-junior/agent.ts
Normal file
119
src/agents/sisyphus-junior/agent.ts
Normal file
@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Sisyphus-Junior - Focused Task Executor
|
||||
*
|
||||
* Executes delegated tasks directly without spawning other agents.
|
||||
* Category-spawned executor with domain-specific configurations.
|
||||
*
|
||||
* Routing:
|
||||
* 1. GPT models (openai/*, github-copilot/gpt-*) -> gpt.ts (GPT-5.2 optimized)
|
||||
* 2. Default (Claude, etc.) -> default.ts (Claude-optimized)
|
||||
*/
|
||||
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentMode } from "../types"
|
||||
import { isGptModel } from "../types"
|
||||
import type { AgentOverrideConfig } from "../../config/schema"
|
||||
import {
|
||||
createAgentToolRestrictions,
|
||||
type PermissionValue,
|
||||
} from "../../shared/permission-compat"
|
||||
|
||||
import { buildDefaultSisyphusJuniorPrompt } from "./default"
|
||||
import { buildGptSisyphusJuniorPrompt } from "./gpt"
|
||||
|
||||
const MODE: AgentMode = "subagent"
|
||||
|
||||
// Core tools that Sisyphus-Junior must NEVER have access to
|
||||
// Note: call_omo_agent is ALLOWED so subagents can spawn explore/librarian
|
||||
const BLOCKED_TOOLS = ["task"]
|
||||
|
||||
export const SISYPHUS_JUNIOR_DEFAULTS = {
|
||||
model: "anthropic/claude-sonnet-4-5",
|
||||
temperature: 0.1,
|
||||
} as const
|
||||
|
||||
export type SisyphusJuniorPromptSource = "default" | "gpt"
|
||||
|
||||
/**
|
||||
* Determines which Sisyphus-Junior prompt to use based on model.
|
||||
*/
|
||||
export function getSisyphusJuniorPromptSource(model?: string): SisyphusJuniorPromptSource {
|
||||
if (model && isGptModel(model)) {
|
||||
return "gpt"
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the appropriate Sisyphus-Junior prompt based on model.
|
||||
*/
|
||||
export function buildSisyphusJuniorPrompt(
|
||||
model: string | undefined,
|
||||
useTaskSystem: boolean,
|
||||
promptAppend?: string
|
||||
): string {
|
||||
const source = getSisyphusJuniorPromptSource(model)
|
||||
|
||||
switch (source) {
|
||||
case "gpt":
|
||||
return buildGptSisyphusJuniorPrompt(useTaskSystem, promptAppend)
|
||||
case "default":
|
||||
default:
|
||||
return buildDefaultSisyphusJuniorPrompt(useTaskSystem, promptAppend)
|
||||
}
|
||||
}
|
||||
|
||||
export function createSisyphusJuniorAgentWithOverrides(
|
||||
override: AgentOverrideConfig | undefined,
|
||||
systemDefaultModel?: string,
|
||||
useTaskSystem = false
|
||||
): AgentConfig {
|
||||
if (override?.disable) {
|
||||
override = undefined
|
||||
}
|
||||
|
||||
const overrideModel = (override as { model?: string } | undefined)?.model
|
||||
const model = overrideModel ?? systemDefaultModel ?? SISYPHUS_JUNIOR_DEFAULTS.model
|
||||
const temperature = override?.temperature ?? SISYPHUS_JUNIOR_DEFAULTS.temperature
|
||||
|
||||
const promptAppend = override?.prompt_append
|
||||
const prompt = buildSisyphusJuniorPrompt(model, useTaskSystem, promptAppend)
|
||||
|
||||
const baseRestrictions = createAgentToolRestrictions(BLOCKED_TOOLS)
|
||||
|
||||
const userPermission = (override?.permission ?? {}) as Record<string, PermissionValue>
|
||||
const basePermission = baseRestrictions.permission
|
||||
const merged: Record<string, PermissionValue> = { ...userPermission }
|
||||
for (const tool of BLOCKED_TOOLS) {
|
||||
merged[tool] = "deny"
|
||||
}
|
||||
merged.call_omo_agent = "allow"
|
||||
const toolsConfig = { permission: { ...merged, ...basePermission } }
|
||||
|
||||
const base: AgentConfig = {
|
||||
description: override?.description ??
|
||||
"Focused task executor. Same discipline, no delegation. (Sisyphus-Junior - OhMyOpenCode)",
|
||||
mode: MODE,
|
||||
model,
|
||||
temperature,
|
||||
maxTokens: 64000,
|
||||
prompt,
|
||||
color: override?.color ?? "#20B2AA",
|
||||
...toolsConfig,
|
||||
}
|
||||
|
||||
if (override?.top_p !== undefined) {
|
||||
base.top_p = override.top_p
|
||||
}
|
||||
|
||||
if (isGptModel(model)) {
|
||||
return { ...base, reasoningEffort: "medium" } as AgentConfig
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
thinking: { type: "enabled", budgetTokens: 32000 },
|
||||
} as AgentConfig
|
||||
}
|
||||
|
||||
createSisyphusJuniorAgentWithOverrides.mode = MODE
|
||||
@ -1,121 +1,10 @@
|
||||
/**
|
||||
* Sisyphus-Junior - Focused Task Executor
|
||||
*
|
||||
* Executes delegated tasks directly without spawning other agents.
|
||||
* Category-spawned executor with domain-specific configurations.
|
||||
*
|
||||
* Routing:
|
||||
* 1. GPT models (openai/*, github-copilot/gpt-*) -> gpt.ts (GPT-5.2 optimized)
|
||||
* 2. Default (Claude, etc.) -> default.ts (Claude-optimized)
|
||||
*/
|
||||
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentMode } from "../types"
|
||||
import { isGptModel } from "../types"
|
||||
import type { AgentOverrideConfig } from "../../config/schema"
|
||||
import {
|
||||
createAgentToolRestrictions,
|
||||
type PermissionValue,
|
||||
} from "../../shared/permission-compat"
|
||||
|
||||
import { buildDefaultSisyphusJuniorPrompt } from "./default"
|
||||
import { buildGptSisyphusJuniorPrompt } from "./gpt"
|
||||
|
||||
export { buildDefaultSisyphusJuniorPrompt } from "./default"
|
||||
export { buildGptSisyphusJuniorPrompt } from "./gpt"
|
||||
|
||||
const MODE: AgentMode = "subagent"
|
||||
|
||||
// Core tools that Sisyphus-Junior must NEVER have access to
|
||||
// Note: call_omo_agent is ALLOWED so subagents can spawn explore/librarian
|
||||
const BLOCKED_TOOLS = ["task"]
|
||||
|
||||
export const SISYPHUS_JUNIOR_DEFAULTS = {
|
||||
model: "anthropic/claude-sonnet-4-5",
|
||||
temperature: 0.1,
|
||||
} as const
|
||||
|
||||
export type SisyphusJuniorPromptSource = "default" | "gpt"
|
||||
|
||||
/**
|
||||
* Determines which Sisyphus-Junior prompt to use based on model.
|
||||
*/
|
||||
export function getSisyphusJuniorPromptSource(model?: string): SisyphusJuniorPromptSource {
|
||||
if (model && isGptModel(model)) {
|
||||
return "gpt"
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the appropriate Sisyphus-Junior prompt based on model.
|
||||
*/
|
||||
export function buildSisyphusJuniorPrompt(
|
||||
model: string | undefined,
|
||||
useTaskSystem: boolean,
|
||||
promptAppend?: string
|
||||
): string {
|
||||
const source = getSisyphusJuniorPromptSource(model)
|
||||
|
||||
switch (source) {
|
||||
case "gpt":
|
||||
return buildGptSisyphusJuniorPrompt(useTaskSystem, promptAppend)
|
||||
case "default":
|
||||
default:
|
||||
return buildDefaultSisyphusJuniorPrompt(useTaskSystem, promptAppend)
|
||||
}
|
||||
}
|
||||
|
||||
export function createSisyphusJuniorAgentWithOverrides(
|
||||
override: AgentOverrideConfig | undefined,
|
||||
systemDefaultModel?: string,
|
||||
useTaskSystem = false
|
||||
): AgentConfig {
|
||||
if (override?.disable) {
|
||||
override = undefined
|
||||
}
|
||||
|
||||
const model = override?.model ?? systemDefaultModel ?? SISYPHUS_JUNIOR_DEFAULTS.model
|
||||
const temperature = override?.temperature ?? SISYPHUS_JUNIOR_DEFAULTS.temperature
|
||||
|
||||
const promptAppend = override?.prompt_append
|
||||
const prompt = buildSisyphusJuniorPrompt(model, useTaskSystem, promptAppend)
|
||||
|
||||
const baseRestrictions = createAgentToolRestrictions(BLOCKED_TOOLS)
|
||||
|
||||
const userPermission = (override?.permission ?? {}) as Record<string, PermissionValue>
|
||||
const basePermission = baseRestrictions.permission
|
||||
const merged: Record<string, PermissionValue> = { ...userPermission }
|
||||
for (const tool of BLOCKED_TOOLS) {
|
||||
merged[tool] = "deny"
|
||||
}
|
||||
merged.call_omo_agent = "allow"
|
||||
const toolsConfig = { permission: { ...merged, ...basePermission } }
|
||||
|
||||
const base: AgentConfig = {
|
||||
description: override?.description ??
|
||||
"Focused task executor. Same discipline, no delegation. (Sisyphus-Junior - OhMyOpenCode)",
|
||||
mode: MODE,
|
||||
model,
|
||||
temperature,
|
||||
maxTokens: 64000,
|
||||
prompt,
|
||||
color: override?.color ?? "#20B2AA",
|
||||
...toolsConfig,
|
||||
}
|
||||
|
||||
if (override?.top_p !== undefined) {
|
||||
base.top_p = override.top_p
|
||||
}
|
||||
|
||||
if (isGptModel(model)) {
|
||||
return { ...base, reasoningEffort: "medium" } as AgentConfig
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
thinking: { type: "enabled", budgetTokens: 32000 },
|
||||
} as AgentConfig
|
||||
}
|
||||
|
||||
createSisyphusJuniorAgentWithOverrides.mode = MODE
|
||||
export {
|
||||
SISYPHUS_JUNIOR_DEFAULTS,
|
||||
getSisyphusJuniorPromptSource,
|
||||
buildSisyphusJuniorPrompt,
|
||||
createSisyphusJuniorAgentWithOverrides,
|
||||
} from "./agent"
|
||||
export type { SisyphusJuniorPromptSource } from "./agent"
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test"
|
||||
import { createBuiltinAgents } from "./utils"
|
||||
import { createBuiltinAgents } from "./builtin-agents"
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import { clearSkillCache } from "../features/opencode-skill-loader/skill-content"
|
||||
import * as connectedProvidersCache from "../shared/connected-providers-cache"
|
||||
@ -543,7 +545,7 @@ describe("createBuiltinAgents with requiresAnyModel gating (sisyphus)", () => {
|
||||
})
|
||||
|
||||
describe("buildAgent with category and skills", () => {
|
||||
const { buildAgent } = require("./utils")
|
||||
const { buildAgent } = require("./agent-builder")
|
||||
const TEST_MODEL = "anthropic/claude-opus-4-6"
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@ -1,485 +0,0 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { BuiltinAgentName, AgentOverrideConfig, AgentOverrides, AgentFactory, AgentPromptMetadata } from "./types"
|
||||
import type { CategoriesConfig, CategoryConfig, GitMasterConfig } from "../config/schema"
|
||||
import { createSisyphusAgent } from "./sisyphus"
|
||||
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, metisPromptMetadata } from "./metis"
|
||||
import { createAtlasAgent, atlasPromptMetadata } from "./atlas"
|
||||
import { createMomusAgent, momusPromptMetadata } from "./momus"
|
||||
import { createHephaestusAgent } from "./hephaestus"
|
||||
import type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder"
|
||||
import { deepMerge, fetchAvailableModels, resolveModelPipeline, AGENT_MODEL_REQUIREMENTS, readConnectedProvidersCache, isModelAvailable, isAnyFallbackModelAvailable, isAnyProviderConnected, migrateAgentConfig } 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"
|
||||
import type { LoadedSkill, SkillScope } from "../features/opencode-skill-loader/types"
|
||||
import type { BrowserAutomationProvider } from "../config/schema"
|
||||
|
||||
type AgentSource = AgentFactory | AgentConfig
|
||||
|
||||
const agentSources: Record<BuiltinAgentName, AgentSource> = {
|
||||
sisyphus: createSisyphusAgent,
|
||||
hephaestus: createHephaestusAgent,
|
||||
oracle: createOracleAgent,
|
||||
librarian: createLibrarianAgent,
|
||||
explore: createExploreAgent,
|
||||
"multimodal-looker": createMultimodalLookerAgent,
|
||||
metis: createMetisAgent,
|
||||
momus: createMomusAgent,
|
||||
// Note: Atlas is handled specially in createBuiltinAgents()
|
||||
// because it needs OrchestratorContext, not just a model string
|
||||
atlas: createAtlasAgent as unknown as AgentFactory,
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata for each agent, used to build Sisyphus's dynamic prompt sections
|
||||
* (Delegation Table, Tool Selection, Key Triggers, etc.)
|
||||
*/
|
||||
const agentMetadata: Partial<Record<BuiltinAgentName, AgentPromptMetadata>> = {
|
||||
oracle: ORACLE_PROMPT_METADATA,
|
||||
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 {
|
||||
return typeof source === "function"
|
||||
}
|
||||
|
||||
export function buildAgent(
|
||||
source: AgentSource,
|
||||
model: string,
|
||||
categories?: CategoriesConfig,
|
||||
gitMasterConfig?: GitMasterConfig,
|
||||
browserProvider?: BrowserAutomationProvider,
|
||||
disabledSkills?: Set<string>
|
||||
): AgentConfig {
|
||||
const base = isFactory(source) ? source(model) : source
|
||||
const categoryConfigs: Record<string, CategoryConfig> = categories
|
||||
? { ...DEFAULT_CATEGORIES, ...categories }
|
||||
: DEFAULT_CATEGORIES
|
||||
|
||||
const agentWithCategory = base as AgentConfig & { category?: string; skills?: string[]; variant?: string }
|
||||
if (agentWithCategory.category) {
|
||||
const categoryConfig = categoryConfigs[agentWithCategory.category]
|
||||
if (categoryConfig) {
|
||||
if (!base.model) {
|
||||
base.model = categoryConfig.model
|
||||
}
|
||||
if (base.temperature === undefined && categoryConfig.temperature !== undefined) {
|
||||
base.temperature = categoryConfig.temperature
|
||||
}
|
||||
if (base.variant === undefined && categoryConfig.variant !== undefined) {
|
||||
base.variant = categoryConfig.variant
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (agentWithCategory.skills?.length) {
|
||||
const { resolved } = resolveMultipleSkills(agentWithCategory.skills, { gitMasterConfig, browserProvider, disabledSkills })
|
||||
if (resolved.size > 0) {
|
||||
const skillContent = Array.from(resolved.values()).join("\n\n")
|
||||
base.prompt = skillContent + (base.prompt ? "\n\n" + base.prompt : "")
|
||||
}
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates OmO-specific environment context (time, timezone, locale).
|
||||
* Note: Working directory, platform, and date are already provided by OpenCode's system.ts,
|
||||
* so we only include fields that OpenCode doesn't provide to avoid duplication.
|
||||
* See: https://github.com/code-yeongyu/oh-my-opencode/issues/379
|
||||
*/
|
||||
export function createEnvContext(): string {
|
||||
const now = new Date()
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const locale = Intl.DateTimeFormat().resolvedOptions().locale
|
||||
|
||||
const dateStr = now.toLocaleDateString(locale, {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})
|
||||
|
||||
const timeStr = now.toLocaleTimeString(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: true,
|
||||
})
|
||||
|
||||
return `
|
||||
<omo-env>
|
||||
Current date: ${dateStr}
|
||||
Current time: ${timeStr}
|
||||
Timezone: ${timezone}
|
||||
Locale: ${locale}
|
||||
</omo-env>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands a category reference from an agent override into concrete config properties.
|
||||
* Category properties are applied unconditionally (overwriting factory defaults),
|
||||
* because the user's chosen category should take priority over factory base values.
|
||||
* Direct override properties applied later via mergeAgentConfig() will supersede these.
|
||||
*/
|
||||
function applyCategoryOverride(
|
||||
config: AgentConfig,
|
||||
categoryName: string,
|
||||
mergedCategories: Record<string, CategoryConfig>
|
||||
): AgentConfig {
|
||||
const categoryConfig = mergedCategories[categoryName]
|
||||
if (!categoryConfig) return config
|
||||
|
||||
const result = { ...config } as AgentConfig & Record<string, unknown>
|
||||
if (categoryConfig.model) result.model = categoryConfig.model
|
||||
if (categoryConfig.variant !== undefined) result.variant = categoryConfig.variant
|
||||
if (categoryConfig.temperature !== undefined) result.temperature = categoryConfig.temperature
|
||||
if (categoryConfig.reasoningEffort !== undefined) result.reasoningEffort = categoryConfig.reasoningEffort
|
||||
if (categoryConfig.textVerbosity !== undefined) result.textVerbosity = categoryConfig.textVerbosity
|
||||
if (categoryConfig.thinking !== undefined) result.thinking = categoryConfig.thinking
|
||||
if (categoryConfig.top_p !== undefined) result.top_p = categoryConfig.top_p
|
||||
if (categoryConfig.maxTokens !== undefined) result.maxTokens = categoryConfig.maxTokens
|
||||
|
||||
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 getFirstFallbackModel(requirement?: {
|
||||
fallbackChain?: { providers: string[]; model: string; variant?: string }[]
|
||||
}) {
|
||||
const entry = requirement?.fallbackChain?.[0]
|
||||
if (!entry || entry.providers.length === 0) return undefined
|
||||
return {
|
||||
model: `${entry.providers[0]}/${entry.model}`,
|
||||
provenance: "provider-fallback" as const,
|
||||
variant: entry.variant,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
): AgentConfig {
|
||||
const migratedOverride = migrateAgentConfig(override as Record<string, unknown>) as AgentOverrideConfig
|
||||
const { prompt_append, ...rest } = migratedOverride
|
||||
const merged = deepMerge(base, rest as Partial<AgentConfig>)
|
||||
|
||||
if (prompt_append && merged.prompt) {
|
||||
merged.prompt = merged.prompt + "\n" + prompt_append
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
function mapScopeToLocation(scope: SkillScope): AvailableSkill["location"] {
|
||||
if (scope === "user" || scope === "opencode") return "user"
|
||||
if (scope === "project" || scope === "opencode-project") return "project"
|
||||
return "plugin"
|
||||
}
|
||||
|
||||
export async function createBuiltinAgents(
|
||||
disabledAgents: string[] = [],
|
||||
agentOverrides: AgentOverrides = {},
|
||||
directory?: string,
|
||||
systemDefaultModel?: string,
|
||||
categories?: CategoriesConfig,
|
||||
gitMasterConfig?: GitMasterConfig,
|
||||
discoveredSkills: LoadedSkill[] = [],
|
||||
client?: any,
|
||||
browserProvider?: BrowserAutomationProvider,
|
||||
uiSelectedModel?: string,
|
||||
disabledSkills?: Set<string>
|
||||
): Promise<Record<string, AgentConfig>> {
|
||||
const connectedProviders = readConnectedProvidersCache()
|
||||
// IMPORTANT: Do NOT pass client to fetchAvailableModels during plugin initialization.
|
||||
// This function is called from config handler, and calling client API causes deadlock.
|
||||
// See: https://github.com/code-yeongyu/oh-my-opencode/issues/1301
|
||||
const availableModels = await fetchAvailableModels(undefined, {
|
||||
connectedProviders: connectedProviders ?? undefined,
|
||||
})
|
||||
const isFirstRunNoCache =
|
||||
availableModels.size === 0 && (!connectedProviders || connectedProviders.length === 0)
|
||||
|
||||
const result: Record<string, AgentConfig> = {}
|
||||
const availableAgents: AvailableAgent[] = []
|
||||
|
||||
const mergedCategories = categories
|
||||
? { ...DEFAULT_CATEGORIES, ...categories }
|
||||
: DEFAULT_CATEGORIES
|
||||
|
||||
const availableCategories: AvailableCategory[] = Object.entries(mergedCategories).map(([name]) => ({
|
||||
name,
|
||||
description: categories?.[name]?.description ?? CATEGORY_DESCRIPTIONS[name] ?? "General tasks",
|
||||
}))
|
||||
|
||||
const builtinSkills = createBuiltinSkills({ browserProvider, disabledSkills })
|
||||
const builtinSkillNames = new Set(builtinSkills.map(s => s.name))
|
||||
|
||||
const builtinAvailable: AvailableSkill[] = builtinSkills.map((skill) => ({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
location: "plugin" as const,
|
||||
}))
|
||||
|
||||
const discoveredAvailable: AvailableSkill[] = discoveredSkills
|
||||
.filter(s => !builtinSkillNames.has(s.name))
|
||||
.map((skill) => ({
|
||||
name: skill.name,
|
||||
description: skill.definition.description ?? "",
|
||||
location: mapScopeToLocation(skill.scope),
|
||||
}))
|
||||
|
||||
const availableSkills: AvailableSkill[] = [...builtinAvailable, ...discoveredAvailable]
|
||||
|
||||
// Collect general agents first (for availableAgents), but don't add to result yet
|
||||
const pendingAgentConfigs: Map<string, AgentConfig> = new Map()
|
||||
|
||||
for (const [name, source] of Object.entries(agentSources)) {
|
||||
const agentName = name as BuiltinAgentName
|
||||
|
||||
if (agentName === "sisyphus") continue
|
||||
if (agentName === "hephaestus") continue
|
||||
if (agentName === "atlas") continue
|
||||
if (disabledAgents.some((name) => name.toLowerCase() === agentName.toLowerCase())) continue
|
||||
|
||||
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
|
||||
if (requirement?.requiresModel && availableModels) {
|
||||
if (!isModelAvailable(requirement.requiresModel, availableModels)) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const isPrimaryAgent = isFactory(source) && source.mode === "primary"
|
||||
|
||||
const resolution = applyModelResolution({
|
||||
uiSelectedModel: (isPrimaryAgent && !override?.model) ? uiSelectedModel : undefined,
|
||||
userModel: override?.model,
|
||||
requirement,
|
||||
availableModels,
|
||||
systemDefaultModel,
|
||||
})
|
||||
if (!resolution) continue
|
||||
const { model, variant: resolvedVariant } = resolution
|
||||
|
||||
let config = buildAgent(source, model, mergedCategories, gitMasterConfig, browserProvider, disabledSkills)
|
||||
|
||||
// Apply resolved variant from model fallback chain
|
||||
if (resolvedVariant) {
|
||||
config = { ...config, variant: resolvedVariant }
|
||||
}
|
||||
|
||||
// Expand override.category into concrete properties (higher priority than factory/resolved)
|
||||
const overrideCategory = (override as Record<string, unknown> | undefined)?.category as string | undefined
|
||||
if (overrideCategory) {
|
||||
config = applyCategoryOverride(config, overrideCategory, mergedCategories)
|
||||
}
|
||||
|
||||
if (agentName === "librarian") {
|
||||
config = applyEnvironmentContext(config, directory)
|
||||
}
|
||||
|
||||
config = applyOverrides(config, override, mergedCategories)
|
||||
|
||||
// Store for later - will be added after sisyphus and hephaestus
|
||||
pendingAgentConfigs.set(name, config)
|
||||
|
||||
const metadata = agentMetadata[agentName]
|
||||
if (metadata) {
|
||||
availableAgents.push({
|
||||
name: agentName,
|
||||
description: config.description ?? "",
|
||||
metadata,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const sisyphusOverride = agentOverrides["sisyphus"]
|
||||
const sisyphusRequirement = AGENT_MODEL_REQUIREMENTS["sisyphus"]
|
||||
const hasSisyphusExplicitConfig = sisyphusOverride !== undefined
|
||||
const meetsSisyphusAnyModelRequirement =
|
||||
!sisyphusRequirement?.requiresAnyModel ||
|
||||
hasSisyphusExplicitConfig ||
|
||||
isFirstRunNoCache ||
|
||||
isAnyFallbackModelAvailable(sisyphusRequirement.fallbackChain, availableModels)
|
||||
|
||||
if (!disabledAgents.includes("sisyphus") && meetsSisyphusAnyModelRequirement) {
|
||||
let sisyphusResolution = applyModelResolution({
|
||||
uiSelectedModel: sisyphusOverride?.model ? undefined : uiSelectedModel,
|
||||
userModel: sisyphusOverride?.model,
|
||||
requirement: sisyphusRequirement,
|
||||
availableModels,
|
||||
systemDefaultModel,
|
||||
})
|
||||
|
||||
if (isFirstRunNoCache && !sisyphusOverride?.model && !uiSelectedModel) {
|
||||
sisyphusResolution = getFirstFallbackModel(sisyphusRequirement)
|
||||
}
|
||||
|
||||
if (sisyphusResolution) {
|
||||
const { model: sisyphusModel, variant: sisyphusResolvedVariant } = sisyphusResolution
|
||||
|
||||
let sisyphusConfig = createSisyphusAgent(
|
||||
sisyphusModel,
|
||||
availableAgents,
|
||||
undefined,
|
||||
availableSkills,
|
||||
availableCategories
|
||||
)
|
||||
|
||||
if (sisyphusResolvedVariant) {
|
||||
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusResolvedVariant }
|
||||
}
|
||||
|
||||
sisyphusConfig = applyOverrides(sisyphusConfig, sisyphusOverride, mergedCategories)
|
||||
sisyphusConfig = applyEnvironmentContext(sisyphusConfig, directory)
|
||||
|
||||
result["sisyphus"] = sisyphusConfig
|
||||
}
|
||||
}
|
||||
|
||||
if (!disabledAgents.includes("hephaestus")) {
|
||||
const hephaestusOverride = agentOverrides["hephaestus"]
|
||||
const hephaestusRequirement = AGENT_MODEL_REQUIREMENTS["hephaestus"]
|
||||
const hasHephaestusExplicitConfig = hephaestusOverride !== undefined
|
||||
|
||||
const hasRequiredProvider =
|
||||
!hephaestusRequirement?.requiresProvider ||
|
||||
hasHephaestusExplicitConfig ||
|
||||
isFirstRunNoCache ||
|
||||
isAnyProviderConnected(hephaestusRequirement.requiresProvider, availableModels)
|
||||
|
||||
if (hasRequiredProvider) {
|
||||
let hephaestusResolution = applyModelResolution({
|
||||
userModel: hephaestusOverride?.model,
|
||||
requirement: hephaestusRequirement,
|
||||
availableModels,
|
||||
systemDefaultModel,
|
||||
})
|
||||
|
||||
if (isFirstRunNoCache && !hephaestusOverride?.model) {
|
||||
hephaestusResolution = getFirstFallbackModel(hephaestusRequirement)
|
||||
}
|
||||
|
||||
if (hephaestusResolution) {
|
||||
const { model: hephaestusModel, variant: hephaestusResolvedVariant } = hephaestusResolution
|
||||
|
||||
let hephaestusConfig = createHephaestusAgent(
|
||||
hephaestusModel,
|
||||
availableAgents,
|
||||
undefined,
|
||||
availableSkills,
|
||||
availableCategories
|
||||
)
|
||||
|
||||
hephaestusConfig = { ...hephaestusConfig, variant: hephaestusResolvedVariant ?? "medium" }
|
||||
|
||||
const hepOverrideCategory = (hephaestusOverride as Record<string, unknown> | undefined)?.category as string | undefined
|
||||
if (hepOverrideCategory) {
|
||||
hephaestusConfig = applyCategoryOverride(hephaestusConfig, hepOverrideCategory, mergedCategories)
|
||||
}
|
||||
|
||||
if (directory && hephaestusConfig.prompt) {
|
||||
const envContext = createEnvContext()
|
||||
hephaestusConfig = { ...hephaestusConfig, prompt: hephaestusConfig.prompt + envContext }
|
||||
}
|
||||
|
||||
if (hephaestusOverride) {
|
||||
hephaestusConfig = mergeAgentConfig(hephaestusConfig, hephaestusOverride)
|
||||
}
|
||||
|
||||
result["hephaestus"] = hephaestusConfig
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add pending agents after sisyphus and hephaestus to maintain order
|
||||
for (const [name, config] of pendingAgentConfigs) {
|
||||
result[name] = config
|
||||
}
|
||||
|
||||
if (!disabledAgents.includes("atlas")) {
|
||||
const orchestratorOverride = agentOverrides["atlas"]
|
||||
const atlasRequirement = AGENT_MODEL_REQUIREMENTS["atlas"]
|
||||
|
||||
const atlasResolution = applyModelResolution({
|
||||
uiSelectedModel: orchestratorOverride?.model ? undefined : uiSelectedModel,
|
||||
userModel: orchestratorOverride?.model,
|
||||
requirement: atlasRequirement,
|
||||
availableModels,
|
||||
systemDefaultModel,
|
||||
})
|
||||
|
||||
if (atlasResolution) {
|
||||
const { model: atlasModel, variant: atlasResolvedVariant } = atlasResolution
|
||||
|
||||
let orchestratorConfig = createAtlasAgent({
|
||||
model: atlasModel,
|
||||
availableAgents,
|
||||
availableSkills,
|
||||
userCategories: categories,
|
||||
})
|
||||
|
||||
if (atlasResolvedVariant) {
|
||||
orchestratorConfig = { ...orchestratorConfig, variant: atlasResolvedVariant }
|
||||
}
|
||||
|
||||
orchestratorConfig = applyOverrides(orchestratorConfig, orchestratorOverride, mergedCategories)
|
||||
|
||||
result["atlas"] = orchestratorConfig
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@ -1,10 +1,9 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs"
|
||||
import {
|
||||
parseJsonc,
|
||||
getOpenCodeConfigPaths,
|
||||
type OpenCodeBinaryType,
|
||||
type OpenCodeConfigPaths,
|
||||
} from "../shared"
|
||||
import { parseJsonc, getOpenCodeConfigPaths } from "../shared"
|
||||
import type {
|
||||
OpenCodeBinaryType,
|
||||
OpenCodeConfigPaths,
|
||||
} from "../shared/opencode-config-dir-types"
|
||||
import type { ConfigMergeResult, DetectedConfig, InstallConfig } from "./types"
|
||||
import { generateModelConfig } from "./model-fallback"
|
||||
|
||||
@ -47,10 +46,6 @@ function getConfigJsonc(): string {
|
||||
return getConfigContext().paths.configJsonc
|
||||
}
|
||||
|
||||
function getPackageJson(): string {
|
||||
return getConfigContext().paths.packageJson
|
||||
}
|
||||
|
||||
function getOmoConfig(): string {
|
||||
return getConfigContext().paths.omoConfig
|
||||
}
|
||||
@ -179,11 +174,6 @@ function isEmptyOrWhitespace(content: string): boolean {
|
||||
return content.trim().length === 0
|
||||
}
|
||||
|
||||
function parseConfig(path: string, _isJsonc: boolean): OpenCodeConfig | null {
|
||||
const result = parseConfigWithError(path)
|
||||
return result.config
|
||||
}
|
||||
|
||||
function parseConfigWithError(path: string): ParseConfigResult {
|
||||
try {
|
||||
const stat = statSync(path)
|
||||
|
||||
111
src/cli/get-local-version/get-local-version.ts
Normal file
111
src/cli/get-local-version/get-local-version.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import {
|
||||
findPluginEntry,
|
||||
getCachedVersion,
|
||||
getLatestVersion,
|
||||
isLocalDevMode,
|
||||
} from "../../hooks/auto-update-checker/checker"
|
||||
|
||||
import type { GetLocalVersionOptions, VersionInfo } from "./types"
|
||||
import { formatJsonOutput, formatVersionOutput } from "./formatter"
|
||||
|
||||
export async function getLocalVersion(
|
||||
options: GetLocalVersionOptions = {}
|
||||
): Promise<number> {
|
||||
const directory = options.directory ?? process.cwd()
|
||||
|
||||
try {
|
||||
if (isLocalDevMode(directory)) {
|
||||
const currentVersion = getCachedVersion()
|
||||
const info: VersionInfo = {
|
||||
currentVersion,
|
||||
latestVersion: null,
|
||||
isUpToDate: false,
|
||||
isLocalDev: true,
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
status: "local-dev",
|
||||
}
|
||||
|
||||
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
|
||||
return 0
|
||||
}
|
||||
|
||||
const pluginInfo = findPluginEntry(directory)
|
||||
if (pluginInfo?.isPinned) {
|
||||
const info: VersionInfo = {
|
||||
currentVersion: pluginInfo.pinnedVersion,
|
||||
latestVersion: null,
|
||||
isUpToDate: false,
|
||||
isLocalDev: false,
|
||||
isPinned: true,
|
||||
pinnedVersion: pluginInfo.pinnedVersion,
|
||||
status: "pinned",
|
||||
}
|
||||
|
||||
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
|
||||
return 0
|
||||
}
|
||||
|
||||
const currentVersion = getCachedVersion()
|
||||
if (!currentVersion) {
|
||||
const info: VersionInfo = {
|
||||
currentVersion: null,
|
||||
latestVersion: null,
|
||||
isUpToDate: false,
|
||||
isLocalDev: false,
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
status: "unknown",
|
||||
}
|
||||
|
||||
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
|
||||
return 1
|
||||
}
|
||||
|
||||
const { extractChannel } = await import("../../hooks/auto-update-checker/index")
|
||||
const channel = extractChannel(pluginInfo?.pinnedVersion ?? currentVersion)
|
||||
const latestVersion = await getLatestVersion(channel)
|
||||
|
||||
if (!latestVersion) {
|
||||
const info: VersionInfo = {
|
||||
currentVersion,
|
||||
latestVersion: null,
|
||||
isUpToDate: false,
|
||||
isLocalDev: false,
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
status: "error",
|
||||
}
|
||||
|
||||
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
|
||||
return 0
|
||||
}
|
||||
|
||||
const isUpToDate = currentVersion === latestVersion
|
||||
const info: VersionInfo = {
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
isUpToDate,
|
||||
isLocalDev: false,
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
status: isUpToDate ? "up-to-date" : "outdated",
|
||||
}
|
||||
|
||||
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
|
||||
return 0
|
||||
} catch (error) {
|
||||
const info: VersionInfo = {
|
||||
currentVersion: null,
|
||||
latestVersion: null,
|
||||
isUpToDate: false,
|
||||
isLocalDev: false,
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
status: "error",
|
||||
}
|
||||
|
||||
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
|
||||
return 1
|
||||
}
|
||||
}
|
||||
@ -1,106 +1,2 @@
|
||||
import { getCachedVersion, getLatestVersion, isLocalDevMode, findPluginEntry } from "../../hooks/auto-update-checker/checker"
|
||||
import type { GetLocalVersionOptions, VersionInfo } from "./types"
|
||||
import { formatVersionOutput, formatJsonOutput } from "./formatter"
|
||||
|
||||
export async function getLocalVersion(options: GetLocalVersionOptions = {}): Promise<number> {
|
||||
const directory = options.directory ?? process.cwd()
|
||||
|
||||
try {
|
||||
if (isLocalDevMode(directory)) {
|
||||
const currentVersion = getCachedVersion()
|
||||
const info: VersionInfo = {
|
||||
currentVersion,
|
||||
latestVersion: null,
|
||||
isUpToDate: false,
|
||||
isLocalDev: true,
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
status: "local-dev",
|
||||
}
|
||||
|
||||
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
|
||||
return 0
|
||||
}
|
||||
|
||||
const pluginInfo = findPluginEntry(directory)
|
||||
if (pluginInfo?.isPinned) {
|
||||
const info: VersionInfo = {
|
||||
currentVersion: pluginInfo.pinnedVersion,
|
||||
latestVersion: null,
|
||||
isUpToDate: false,
|
||||
isLocalDev: false,
|
||||
isPinned: true,
|
||||
pinnedVersion: pluginInfo.pinnedVersion,
|
||||
status: "pinned",
|
||||
}
|
||||
|
||||
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
|
||||
return 0
|
||||
}
|
||||
|
||||
const currentVersion = getCachedVersion()
|
||||
if (!currentVersion) {
|
||||
const info: VersionInfo = {
|
||||
currentVersion: null,
|
||||
latestVersion: null,
|
||||
isUpToDate: false,
|
||||
isLocalDev: false,
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
status: "unknown",
|
||||
}
|
||||
|
||||
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
|
||||
return 1
|
||||
}
|
||||
|
||||
const { extractChannel } = await import("../../hooks/auto-update-checker/index")
|
||||
const channel = extractChannel(pluginInfo?.pinnedVersion ?? currentVersion)
|
||||
const latestVersion = await getLatestVersion(channel)
|
||||
|
||||
if (!latestVersion) {
|
||||
const info: VersionInfo = {
|
||||
currentVersion,
|
||||
latestVersion: null,
|
||||
isUpToDate: false,
|
||||
isLocalDev: false,
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
status: "error",
|
||||
}
|
||||
|
||||
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
|
||||
return 0
|
||||
}
|
||||
|
||||
const isUpToDate = currentVersion === latestVersion
|
||||
const info: VersionInfo = {
|
||||
currentVersion,
|
||||
latestVersion,
|
||||
isUpToDate,
|
||||
isLocalDev: false,
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
status: isUpToDate ? "up-to-date" : "outdated",
|
||||
}
|
||||
|
||||
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
|
||||
return 0
|
||||
|
||||
} catch (error) {
|
||||
const info: VersionInfo = {
|
||||
currentVersion: null,
|
||||
latestVersion: null,
|
||||
isUpToDate: false,
|
||||
isLocalDev: false,
|
||||
isPinned: false,
|
||||
pinnedVersion: null,
|
||||
status: "error",
|
||||
}
|
||||
|
||||
console.log(options.json ? formatJsonOutput(info) : formatVersionOutput(info))
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
export { getLocalVersion } from "./get-local-version"
|
||||
export * from "./types"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { spawn } from "bun"
|
||||
import type { WindowState, TmuxPaneInfo } from "./types"
|
||||
import { getTmuxPath } from "../../tools/interactive-bash/utils"
|
||||
import { getTmuxPath } from "../../tools/interactive-bash/tmux-path-resolver"
|
||||
import { log } from "../../shared"
|
||||
|
||||
export async function queryWindowState(sourcePaneId: string): Promise<WindowState | null> {
|
||||
|
||||
@ -1,84 +1,7 @@
|
||||
/**
|
||||
* Pending tool metadata store.
|
||||
*
|
||||
* OpenCode's `fromPlugin()` wrapper always replaces the metadata returned by
|
||||
* plugin tools with `{ truncated, outputPath }`, discarding any sessionId,
|
||||
* title, or custom metadata set during `execute()`.
|
||||
*
|
||||
* This store captures metadata written via `ctx.metadata()` inside execute(),
|
||||
* then the `tool.execute.after` hook consumes it and merges it back into the
|
||||
* result *before* the processor writes the final part to the session store.
|
||||
*
|
||||
* Flow:
|
||||
* execute() → storeToolMetadata(sessionID, callID, data)
|
||||
* fromPlugin() → overwrites metadata with { truncated }
|
||||
* tool.execute.after → consumeToolMetadata(sessionID, callID) → merges back
|
||||
* processor → Session.updatePart(status:"completed", metadata: result.metadata)
|
||||
*/
|
||||
|
||||
export interface PendingToolMetadata {
|
||||
title?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const pendingStore = new Map<string, PendingToolMetadata & { storedAt: number }>()
|
||||
|
||||
const STALE_TIMEOUT_MS = 15 * 60 * 1000
|
||||
|
||||
function makeKey(sessionID: string, callID: string): string {
|
||||
return `${sessionID}:${callID}`
|
||||
}
|
||||
|
||||
function cleanupStaleEntries(): void {
|
||||
const now = Date.now()
|
||||
for (const [key, entry] of pendingStore) {
|
||||
if (now - entry.storedAt > STALE_TIMEOUT_MS) {
|
||||
pendingStore.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store metadata to be restored after fromPlugin() overwrites it.
|
||||
* Called from tool execute() functions alongside ctx.metadata().
|
||||
*/
|
||||
export function storeToolMetadata(
|
||||
sessionID: string,
|
||||
callID: string,
|
||||
data: PendingToolMetadata,
|
||||
): void {
|
||||
cleanupStaleEntries()
|
||||
pendingStore.set(makeKey(sessionID, callID), { ...data, storedAt: Date.now() })
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume stored metadata (one-time read, removes from store).
|
||||
* Called from tool.execute.after hook.
|
||||
*/
|
||||
export function consumeToolMetadata(
|
||||
sessionID: string,
|
||||
callID: string,
|
||||
): PendingToolMetadata | undefined {
|
||||
const key = makeKey(sessionID, callID)
|
||||
const stored = pendingStore.get(key)
|
||||
if (stored) {
|
||||
pendingStore.delete(key)
|
||||
const { storedAt: _, ...data } = stored
|
||||
return data
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current store size (for testing/debugging).
|
||||
*/
|
||||
export function getPendingStoreSize(): number {
|
||||
return pendingStore.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all pending metadata (for testing).
|
||||
*/
|
||||
export function clearPendingStore(): void {
|
||||
pendingStore.clear()
|
||||
}
|
||||
export {
|
||||
clearPendingStore,
|
||||
consumeToolMetadata,
|
||||
getPendingStoreSize,
|
||||
storeToolMetadata,
|
||||
} from "./store"
|
||||
export type { PendingToolMetadata } from "./store"
|
||||
|
||||
84
src/features/tool-metadata-store/store.ts
Normal file
84
src/features/tool-metadata-store/store.ts
Normal file
@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Pending tool metadata store.
|
||||
*
|
||||
* OpenCode's `fromPlugin()` wrapper always replaces the metadata returned by
|
||||
* plugin tools with `{ truncated, outputPath }`, discarding any sessionId,
|
||||
* title, or custom metadata set during `execute()`.
|
||||
*
|
||||
* This store captures metadata written via `ctx.metadata()` inside execute(),
|
||||
* then the `tool.execute.after` hook consumes it and merges it back into the
|
||||
* result *before* the processor writes the final part to the session store.
|
||||
*
|
||||
* Flow:
|
||||
* execute() → storeToolMetadata(sessionID, callID, data)
|
||||
* fromPlugin() → overwrites metadata with { truncated }
|
||||
* tool.execute.after → consumeToolMetadata(sessionID, callID) → merges back
|
||||
* processor → Session.updatePart(status:"completed", metadata: result.metadata)
|
||||
*/
|
||||
|
||||
export interface PendingToolMetadata {
|
||||
title?: string
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const pendingStore = new Map<string, PendingToolMetadata & { storedAt: number }>()
|
||||
|
||||
const STALE_TIMEOUT_MS = 15 * 60 * 1000
|
||||
|
||||
function makeKey(sessionID: string, callID: string): string {
|
||||
return `${sessionID}:${callID}`
|
||||
}
|
||||
|
||||
function cleanupStaleEntries(): void {
|
||||
const now = Date.now()
|
||||
for (const [key, entry] of pendingStore) {
|
||||
if (now - entry.storedAt > STALE_TIMEOUT_MS) {
|
||||
pendingStore.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store metadata to be restored after fromPlugin() overwrites it.
|
||||
* Called from tool execute() functions alongside ctx.metadata().
|
||||
*/
|
||||
export function storeToolMetadata(
|
||||
sessionID: string,
|
||||
callID: string,
|
||||
data: PendingToolMetadata
|
||||
): void {
|
||||
cleanupStaleEntries()
|
||||
pendingStore.set(makeKey(sessionID, callID), { ...data, storedAt: Date.now() })
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume stored metadata (one-time read, removes from store).
|
||||
* Called from tool.execute.after hook.
|
||||
*/
|
||||
export function consumeToolMetadata(
|
||||
sessionID: string,
|
||||
callID: string
|
||||
): PendingToolMetadata | undefined {
|
||||
const key = makeKey(sessionID, callID)
|
||||
const stored = pendingStore.get(key)
|
||||
if (stored) {
|
||||
pendingStore.delete(key)
|
||||
const { storedAt: _, ...data } = stored
|
||||
return data
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current store size (for testing/debugging).
|
||||
*/
|
||||
export function getPendingStoreSize(): number {
|
||||
return pendingStore.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all pending metadata (for testing).
|
||||
*/
|
||||
export function clearPendingStore(): void {
|
||||
pendingStore.clear()
|
||||
}
|
||||
109
src/hooks/agent-usage-reminder/hook.ts
Normal file
109
src/hooks/agent-usage-reminder/hook.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
import {
|
||||
loadAgentUsageState,
|
||||
saveAgentUsageState,
|
||||
clearAgentUsageState,
|
||||
} from "./storage";
|
||||
import { TARGET_TOOLS, AGENT_TOOLS, REMINDER_MESSAGE } from "./constants";
|
||||
import type { AgentUsageState } from "./types";
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string;
|
||||
sessionID: string;
|
||||
callID: string;
|
||||
}
|
||||
|
||||
interface ToolExecuteOutput {
|
||||
title: string;
|
||||
output: string;
|
||||
metadata: unknown;
|
||||
}
|
||||
|
||||
interface EventInput {
|
||||
event: {
|
||||
type: string;
|
||||
properties?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export function createAgentUsageReminderHook(_ctx: PluginInput) {
|
||||
const sessionStates = new Map<string, AgentUsageState>();
|
||||
|
||||
function getOrCreateState(sessionID: string): AgentUsageState {
|
||||
if (!sessionStates.has(sessionID)) {
|
||||
const persisted = loadAgentUsageState(sessionID);
|
||||
const state: AgentUsageState = persisted ?? {
|
||||
sessionID,
|
||||
agentUsed: false,
|
||||
reminderCount: 0,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
sessionStates.set(sessionID, state);
|
||||
}
|
||||
return sessionStates.get(sessionID)!;
|
||||
}
|
||||
|
||||
function markAgentUsed(sessionID: string): void {
|
||||
const state = getOrCreateState(sessionID);
|
||||
state.agentUsed = true;
|
||||
state.updatedAt = Date.now();
|
||||
saveAgentUsageState(state);
|
||||
}
|
||||
|
||||
function resetState(sessionID: string): void {
|
||||
sessionStates.delete(sessionID);
|
||||
clearAgentUsageState(sessionID);
|
||||
}
|
||||
|
||||
const toolExecuteAfter = async (
|
||||
input: ToolExecuteInput,
|
||||
output: ToolExecuteOutput,
|
||||
) => {
|
||||
const { tool, sessionID } = input;
|
||||
const toolLower = tool.toLowerCase();
|
||||
|
||||
if (AGENT_TOOLS.has(toolLower)) {
|
||||
markAgentUsed(sessionID);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TARGET_TOOLS.has(toolLower)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = getOrCreateState(sessionID);
|
||||
|
||||
if (state.agentUsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
output.output += REMINDER_MESSAGE;
|
||||
state.reminderCount++;
|
||||
state.updatedAt = Date.now();
|
||||
saveAgentUsageState(state);
|
||||
};
|
||||
|
||||
const eventHandler = async ({ event }: EventInput) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
if (sessionInfo?.id) {
|
||||
resetState(sessionInfo.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
||||
if (sessionID) {
|
||||
resetState(sessionID);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
event: eventHandler,
|
||||
};
|
||||
}
|
||||
@ -1,109 +1 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
import {
|
||||
loadAgentUsageState,
|
||||
saveAgentUsageState,
|
||||
clearAgentUsageState,
|
||||
} from "./storage";
|
||||
import { TARGET_TOOLS, AGENT_TOOLS, REMINDER_MESSAGE } from "./constants";
|
||||
import type { AgentUsageState } from "./types";
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string;
|
||||
sessionID: string;
|
||||
callID: string;
|
||||
}
|
||||
|
||||
interface ToolExecuteOutput {
|
||||
title: string;
|
||||
output: string;
|
||||
metadata: unknown;
|
||||
}
|
||||
|
||||
interface EventInput {
|
||||
event: {
|
||||
type: string;
|
||||
properties?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export function createAgentUsageReminderHook(_ctx: PluginInput) {
|
||||
const sessionStates = new Map<string, AgentUsageState>();
|
||||
|
||||
function getOrCreateState(sessionID: string): AgentUsageState {
|
||||
if (!sessionStates.has(sessionID)) {
|
||||
const persisted = loadAgentUsageState(sessionID);
|
||||
const state: AgentUsageState = persisted ?? {
|
||||
sessionID,
|
||||
agentUsed: false,
|
||||
reminderCount: 0,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
sessionStates.set(sessionID, state);
|
||||
}
|
||||
return sessionStates.get(sessionID)!;
|
||||
}
|
||||
|
||||
function markAgentUsed(sessionID: string): void {
|
||||
const state = getOrCreateState(sessionID);
|
||||
state.agentUsed = true;
|
||||
state.updatedAt = Date.now();
|
||||
saveAgentUsageState(state);
|
||||
}
|
||||
|
||||
function resetState(sessionID: string): void {
|
||||
sessionStates.delete(sessionID);
|
||||
clearAgentUsageState(sessionID);
|
||||
}
|
||||
|
||||
const toolExecuteAfter = async (
|
||||
input: ToolExecuteInput,
|
||||
output: ToolExecuteOutput,
|
||||
) => {
|
||||
const { tool, sessionID } = input;
|
||||
const toolLower = tool.toLowerCase();
|
||||
|
||||
if (AGENT_TOOLS.has(toolLower)) {
|
||||
markAgentUsed(sessionID);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TARGET_TOOLS.has(toolLower)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const state = getOrCreateState(sessionID);
|
||||
|
||||
if (state.agentUsed) {
|
||||
return;
|
||||
}
|
||||
|
||||
output.output += REMINDER_MESSAGE;
|
||||
state.reminderCount++;
|
||||
state.updatedAt = Date.now();
|
||||
saveAgentUsageState(state);
|
||||
};
|
||||
|
||||
const eventHandler = async ({ event }: EventInput) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
if (sessionInfo?.id) {
|
||||
resetState(sessionInfo.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
||||
if (sessionID) {
|
||||
resetState(sessionID);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
event: eventHandler,
|
||||
};
|
||||
}
|
||||
export { createAgentUsageReminderHook } from "./hook";
|
||||
|
||||
56
src/hooks/anthropic-effort/hook.ts
Normal file
56
src/hooks/anthropic-effort/hook.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { log } from "../../shared"
|
||||
|
||||
const OPUS_4_6_PATTERN = /claude-opus-4[-.]6/i
|
||||
|
||||
function normalizeModelID(modelID: string): string {
|
||||
return modelID.replace(/\.(\d+)/g, "-$1")
|
||||
}
|
||||
|
||||
function isClaudeProvider(providerID: string, modelID: string): boolean {
|
||||
if (["anthropic", "opencode"].includes(providerID)) return true
|
||||
if (providerID === "github-copilot" && modelID.toLowerCase().includes("claude")) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function isOpus46(modelID: string): boolean {
|
||||
const normalized = normalizeModelID(modelID)
|
||||
return OPUS_4_6_PATTERN.test(normalized)
|
||||
}
|
||||
|
||||
interface ChatParamsInput {
|
||||
sessionID: string
|
||||
agent: { name?: string }
|
||||
model: { providerID: string; modelID: string }
|
||||
provider: { id: string }
|
||||
message: { variant?: string }
|
||||
}
|
||||
|
||||
interface ChatParamsOutput {
|
||||
temperature?: number
|
||||
topP?: number
|
||||
topK?: number
|
||||
options: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function createAnthropicEffortHook() {
|
||||
return {
|
||||
"chat.params": async (
|
||||
input: ChatParamsInput,
|
||||
output: ChatParamsOutput
|
||||
): Promise<void> => {
|
||||
const { model, message } = input
|
||||
if (!model?.modelID || !model?.providerID) return
|
||||
if (message.variant !== "max") return
|
||||
if (!isClaudeProvider(model.providerID, model.modelID)) return
|
||||
if (!isOpus46(model.modelID)) return
|
||||
if (output.options.effort !== undefined) return
|
||||
|
||||
output.options.effort = "max"
|
||||
log("anthropic-effort: injected effort=max", {
|
||||
sessionID: input.sessionID,
|
||||
provider: model.providerID,
|
||||
model: model.modelID,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -1,56 +1 @@
|
||||
import { log } from "../../shared"
|
||||
|
||||
const OPUS_4_6_PATTERN = /claude-opus-4[-.]6/i
|
||||
|
||||
function normalizeModelID(modelID: string): string {
|
||||
return modelID.replace(/\.(\d+)/g, "-$1")
|
||||
}
|
||||
|
||||
function isClaudeProvider(providerID: string, modelID: string): boolean {
|
||||
if (["anthropic", "opencode"].includes(providerID)) return true
|
||||
if (providerID === "github-copilot" && modelID.toLowerCase().includes("claude")) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function isOpus46(modelID: string): boolean {
|
||||
const normalized = normalizeModelID(modelID)
|
||||
return OPUS_4_6_PATTERN.test(normalized)
|
||||
}
|
||||
|
||||
interface ChatParamsInput {
|
||||
sessionID: string
|
||||
agent: { name?: string }
|
||||
model: { providerID: string; modelID: string }
|
||||
provider: { id: string }
|
||||
message: { variant?: string }
|
||||
}
|
||||
|
||||
interface ChatParamsOutput {
|
||||
temperature?: number
|
||||
topP?: number
|
||||
topK?: number
|
||||
options: Record<string, unknown>
|
||||
}
|
||||
|
||||
export function createAnthropicEffortHook() {
|
||||
return {
|
||||
"chat.params": async (
|
||||
input: ChatParamsInput,
|
||||
output: ChatParamsOutput
|
||||
): Promise<void> => {
|
||||
const { model, message } = input
|
||||
if (!model?.modelID || !model?.providerID) return
|
||||
if (message.variant !== "max") return
|
||||
if (!isClaudeProvider(model.providerID, model.modelID)) return
|
||||
if (!isOpus46(model.modelID)) return
|
||||
if (output.options.effort !== undefined) return
|
||||
|
||||
output.options.effort = "max"
|
||||
log("anthropic-effort: injected effort=max", {
|
||||
sessionID: input.sessionID,
|
||||
provider: model.providerID,
|
||||
model: model.modelID,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
export { createAnthropicEffortHook } from "./hook";
|
||||
|
||||
145
src/hooks/auto-slash-command/hook.ts
Normal file
145
src/hooks/auto-slash-command/hook.ts
Normal file
@ -0,0 +1,145 @@
|
||||
import {
|
||||
detectSlashCommand,
|
||||
extractPromptText,
|
||||
findSlashCommandPartIndex,
|
||||
} from "./detector"
|
||||
import { executeSlashCommand, type ExecutorOptions } from "./executor"
|
||||
import { log } from "../../shared"
|
||||
import {
|
||||
AUTO_SLASH_COMMAND_TAG_CLOSE,
|
||||
AUTO_SLASH_COMMAND_TAG_OPEN,
|
||||
} from "./constants"
|
||||
import type {
|
||||
AutoSlashCommandHookInput,
|
||||
AutoSlashCommandHookOutput,
|
||||
CommandExecuteBeforeInput,
|
||||
CommandExecuteBeforeOutput,
|
||||
} from "./types"
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
|
||||
const sessionProcessedCommands = new Set<string>()
|
||||
const sessionProcessedCommandExecutions = new Set<string>()
|
||||
|
||||
export interface AutoSlashCommandHookOptions {
|
||||
skills?: LoadedSkill[]
|
||||
}
|
||||
|
||||
export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions) {
|
||||
const executorOptions: ExecutorOptions = {
|
||||
skills: options?.skills,
|
||||
}
|
||||
|
||||
return {
|
||||
"chat.message": async (
|
||||
input: AutoSlashCommandHookInput,
|
||||
output: AutoSlashCommandHookOutput
|
||||
): Promise<void> => {
|
||||
const promptText = extractPromptText(output.parts)
|
||||
|
||||
// Debug logging to diagnose slash command issues
|
||||
if (promptText.startsWith("/")) {
|
||||
log(`[auto-slash-command] chat.message hook received slash command`, {
|
||||
sessionID: input.sessionID,
|
||||
promptText: promptText.slice(0, 100),
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
promptText.includes(AUTO_SLASH_COMMAND_TAG_OPEN) ||
|
||||
promptText.includes(AUTO_SLASH_COMMAND_TAG_CLOSE)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = detectSlashCommand(promptText)
|
||||
|
||||
if (!parsed) {
|
||||
return
|
||||
}
|
||||
|
||||
const commandKey = `${input.sessionID}:${input.messageID}:${parsed.command}`
|
||||
if (sessionProcessedCommands.has(commandKey)) {
|
||||
return
|
||||
}
|
||||
sessionProcessedCommands.add(commandKey)
|
||||
|
||||
log(`[auto-slash-command] Detected: /${parsed.command}`, {
|
||||
sessionID: input.sessionID,
|
||||
args: parsed.args,
|
||||
})
|
||||
|
||||
const result = await executeSlashCommand(parsed, executorOptions)
|
||||
|
||||
const idx = findSlashCommandPartIndex(output.parts)
|
||||
if (idx < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!result.success || !result.replacementText) {
|
||||
log(`[auto-slash-command] Command not found, skipping`, {
|
||||
sessionID: input.sessionID,
|
||||
command: parsed.command,
|
||||
error: result.error,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const taggedContent = `${AUTO_SLASH_COMMAND_TAG_OPEN}\n${result.replacementText}\n${AUTO_SLASH_COMMAND_TAG_CLOSE}`
|
||||
output.parts[idx].text = taggedContent
|
||||
|
||||
log(`[auto-slash-command] Replaced message with command template`, {
|
||||
sessionID: input.sessionID,
|
||||
command: parsed.command,
|
||||
})
|
||||
},
|
||||
|
||||
"command.execute.before": async (
|
||||
input: CommandExecuteBeforeInput,
|
||||
output: CommandExecuteBeforeOutput
|
||||
): Promise<void> => {
|
||||
const commandKey = `${input.sessionID}:${input.command}:${Date.now()}`
|
||||
if (sessionProcessedCommandExecutions.has(commandKey)) {
|
||||
return
|
||||
}
|
||||
|
||||
log(`[auto-slash-command] command.execute.before received`, {
|
||||
sessionID: input.sessionID,
|
||||
command: input.command,
|
||||
arguments: input.arguments,
|
||||
})
|
||||
|
||||
const parsed = {
|
||||
command: input.command,
|
||||
args: input.arguments || "",
|
||||
raw: `/${input.command}${input.arguments ? " " + input.arguments : ""}`,
|
||||
}
|
||||
|
||||
const result = await executeSlashCommand(parsed, executorOptions)
|
||||
|
||||
if (!result.success || !result.replacementText) {
|
||||
log(`[auto-slash-command] command.execute.before - command not found in our executor`, {
|
||||
sessionID: input.sessionID,
|
||||
command: input.command,
|
||||
error: result.error,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sessionProcessedCommandExecutions.add(commandKey)
|
||||
|
||||
const taggedContent = `${AUTO_SLASH_COMMAND_TAG_OPEN}\n${result.replacementText}\n${AUTO_SLASH_COMMAND_TAG_CLOSE}`
|
||||
|
||||
const idx = findSlashCommandPartIndex(output.parts)
|
||||
if (idx >= 0) {
|
||||
output.parts[idx].text = taggedContent
|
||||
} else {
|
||||
output.parts.unshift({ type: "text", text: taggedContent })
|
||||
}
|
||||
|
||||
log(`[auto-slash-command] command.execute.before - injected template`, {
|
||||
sessionID: input.sessionID,
|
||||
command: input.command,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -1,150 +1,7 @@
|
||||
import {
|
||||
detectSlashCommand,
|
||||
extractPromptText,
|
||||
findSlashCommandPartIndex,
|
||||
} from "./detector"
|
||||
import { executeSlashCommand, type ExecutorOptions } from "./executor"
|
||||
import { log } from "../../shared"
|
||||
import {
|
||||
AUTO_SLASH_COMMAND_TAG_OPEN,
|
||||
AUTO_SLASH_COMMAND_TAG_CLOSE,
|
||||
} from "./constants"
|
||||
import type {
|
||||
AutoSlashCommandHookInput,
|
||||
AutoSlashCommandHookOutput,
|
||||
CommandExecuteBeforeInput,
|
||||
CommandExecuteBeforeOutput,
|
||||
} from "./types"
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
|
||||
export * from "./detector"
|
||||
export * from "./executor"
|
||||
export * from "./constants"
|
||||
export * from "./types"
|
||||
|
||||
const sessionProcessedCommands = new Set<string>()
|
||||
const sessionProcessedCommandExecutions = new Set<string>()
|
||||
|
||||
export interface AutoSlashCommandHookOptions {
|
||||
skills?: LoadedSkill[]
|
||||
}
|
||||
|
||||
export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions) {
|
||||
const executorOptions: ExecutorOptions = {
|
||||
skills: options?.skills,
|
||||
}
|
||||
|
||||
return {
|
||||
"chat.message": async (
|
||||
input: AutoSlashCommandHookInput,
|
||||
output: AutoSlashCommandHookOutput
|
||||
): Promise<void> => {
|
||||
const promptText = extractPromptText(output.parts)
|
||||
|
||||
// Debug logging to diagnose slash command issues
|
||||
if (promptText.startsWith("/")) {
|
||||
log(`[auto-slash-command] chat.message hook received slash command`, {
|
||||
sessionID: input.sessionID,
|
||||
promptText: promptText.slice(0, 100),
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
promptText.includes(AUTO_SLASH_COMMAND_TAG_OPEN) ||
|
||||
promptText.includes(AUTO_SLASH_COMMAND_TAG_CLOSE)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const parsed = detectSlashCommand(promptText)
|
||||
|
||||
if (!parsed) {
|
||||
return
|
||||
}
|
||||
|
||||
const commandKey = `${input.sessionID}:${input.messageID}:${parsed.command}`
|
||||
if (sessionProcessedCommands.has(commandKey)) {
|
||||
return
|
||||
}
|
||||
sessionProcessedCommands.add(commandKey)
|
||||
|
||||
log(`[auto-slash-command] Detected: /${parsed.command}`, {
|
||||
sessionID: input.sessionID,
|
||||
args: parsed.args,
|
||||
})
|
||||
|
||||
const result = await executeSlashCommand(parsed, executorOptions)
|
||||
|
||||
const idx = findSlashCommandPartIndex(output.parts)
|
||||
if (idx < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!result.success || !result.replacementText) {
|
||||
log(`[auto-slash-command] Command not found, skipping`, {
|
||||
sessionID: input.sessionID,
|
||||
command: parsed.command,
|
||||
error: result.error,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const taggedContent = `${AUTO_SLASH_COMMAND_TAG_OPEN}\n${result.replacementText}\n${AUTO_SLASH_COMMAND_TAG_CLOSE}`
|
||||
output.parts[idx].text = taggedContent
|
||||
|
||||
log(`[auto-slash-command] Replaced message with command template`, {
|
||||
sessionID: input.sessionID,
|
||||
command: parsed.command,
|
||||
})
|
||||
},
|
||||
|
||||
"command.execute.before": async (
|
||||
input: CommandExecuteBeforeInput,
|
||||
output: CommandExecuteBeforeOutput
|
||||
): Promise<void> => {
|
||||
const commandKey = `${input.sessionID}:${input.command}:${Date.now()}`
|
||||
if (sessionProcessedCommandExecutions.has(commandKey)) {
|
||||
return
|
||||
}
|
||||
|
||||
log(`[auto-slash-command] command.execute.before received`, {
|
||||
sessionID: input.sessionID,
|
||||
command: input.command,
|
||||
arguments: input.arguments,
|
||||
})
|
||||
|
||||
const parsed = {
|
||||
command: input.command,
|
||||
args: input.arguments || "",
|
||||
raw: `/${input.command}${input.arguments ? " " + input.arguments : ""}`,
|
||||
}
|
||||
|
||||
const result = await executeSlashCommand(parsed, executorOptions)
|
||||
|
||||
if (!result.success || !result.replacementText) {
|
||||
log(`[auto-slash-command] command.execute.before - command not found in our executor`, {
|
||||
sessionID: input.sessionID,
|
||||
command: input.command,
|
||||
error: result.error,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
sessionProcessedCommandExecutions.add(commandKey)
|
||||
|
||||
const taggedContent = `${AUTO_SLASH_COMMAND_TAG_OPEN}\n${result.replacementText}\n${AUTO_SLASH_COMMAND_TAG_CLOSE}`
|
||||
|
||||
const idx = findSlashCommandPartIndex(output.parts)
|
||||
if (idx >= 0) {
|
||||
output.parts[idx].text = taggedContent
|
||||
} else {
|
||||
output.parts.unshift({ type: "text", text: taggedContent })
|
||||
}
|
||||
|
||||
log(`[auto-slash-command] command.execute.before - injected template`, {
|
||||
sessionID: input.sessionID,
|
||||
command: input.command,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
export { createAutoSlashCommandHook } from "./hook"
|
||||
export type { AutoSlashCommandHookOptions } from "./hook"
|
||||
|
||||
26
src/hooks/background-notification/hook.ts
Normal file
26
src/hooks/background-notification/hook.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
|
||||
interface Event {
|
||||
type: string
|
||||
properties?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface EventInput {
|
||||
event: Event
|
||||
}
|
||||
|
||||
/**
|
||||
* Background notification hook - handles event routing to BackgroundManager.
|
||||
*
|
||||
* Notifications are now delivered directly via session.prompt({ noReply })
|
||||
* from the manager, so this hook only needs to handle event routing.
|
||||
*/
|
||||
export function createBackgroundNotificationHook(manager: BackgroundManager) {
|
||||
const eventHandler = async ({ event }: EventInput) => {
|
||||
manager.handleEvent(event)
|
||||
}
|
||||
|
||||
return {
|
||||
event: eventHandler,
|
||||
}
|
||||
}
|
||||
@ -1,28 +1,2 @@
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
|
||||
interface Event {
|
||||
type: string
|
||||
properties?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface EventInput {
|
||||
event: Event
|
||||
}
|
||||
|
||||
/**
|
||||
* Background notification hook - handles event routing to BackgroundManager.
|
||||
*
|
||||
* Notifications are now delivered directly via session.prompt({ noReply })
|
||||
* from the manager, so this hook only needs to handle event routing.
|
||||
*/
|
||||
export function createBackgroundNotificationHook(manager: BackgroundManager) {
|
||||
const eventHandler = async ({ event }: EventInput) => {
|
||||
manager.handleEvent(event)
|
||||
}
|
||||
|
||||
return {
|
||||
event: eventHandler,
|
||||
}
|
||||
}
|
||||
|
||||
export { createBackgroundNotificationHook } from "./hook"
|
||||
export type { BackgroundNotificationHookConfig } from "./types"
|
||||
|
||||
37
src/hooks/category-skill-reminder/formatter.ts
Normal file
37
src/hooks/category-skill-reminder/formatter.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder"
|
||||
|
||||
function formatSkillNames(skills: AvailableSkill[], limit: number): string {
|
||||
if (skills.length === 0) return "(none)"
|
||||
const shown = skills.slice(0, limit).map((s) => s.name)
|
||||
const remaining = skills.length - shown.length
|
||||
const suffix = remaining > 0 ? ` (+${remaining} more)` : ""
|
||||
return shown.join(", ") + suffix
|
||||
}
|
||||
|
||||
export function buildReminderMessage(availableSkills: AvailableSkill[]): string {
|
||||
const builtinSkills = availableSkills.filter((s) => s.location === "plugin")
|
||||
const customSkills = availableSkills.filter((s) => s.location !== "plugin")
|
||||
|
||||
const builtinText = formatSkillNames(builtinSkills, 8)
|
||||
const customText = formatSkillNames(customSkills, 8)
|
||||
|
||||
const exampleSkillName = customSkills[0]?.name ?? builtinSkills[0]?.name
|
||||
const loadSkills = exampleSkillName ? `["${exampleSkillName}"]` : "[]"
|
||||
|
||||
const lines = [
|
||||
"",
|
||||
"[Category+Skill Reminder]",
|
||||
"",
|
||||
`**Built-in**: ${builtinText}`,
|
||||
`**⚡ YOUR SKILLS (PRIORITY)**: ${customText}`,
|
||||
"",
|
||||
"> User-installed skills OVERRIDE built-in defaults. ALWAYS prefer YOUR SKILLS when domain matches.",
|
||||
"",
|
||||
"```typescript",
|
||||
`task(category=\"visual-engineering\", load_skills=${loadSkills}, run_in_background=true)`,
|
||||
"```",
|
||||
"",
|
||||
]
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
141
src/hooks/category-skill-reminder/hook.ts
Normal file
141
src/hooks/category-skill-reminder/hook.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder"
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { log } from "../../shared"
|
||||
import { buildReminderMessage } from "./formatter"
|
||||
|
||||
/**
|
||||
* Target agents that should receive category+skill reminders.
|
||||
* These are orchestrator agents that delegate work to specialized agents.
|
||||
*/
|
||||
const TARGET_AGENTS = new Set([
|
||||
"sisyphus",
|
||||
"sisyphus-junior",
|
||||
"atlas",
|
||||
])
|
||||
|
||||
/**
|
||||
* Tools that indicate the agent is doing work that could potentially be delegated.
|
||||
* When these tools are used, we remind the agent about the category+skill system.
|
||||
*/
|
||||
const DELEGATABLE_WORK_TOOLS = new Set([
|
||||
"edit",
|
||||
"write",
|
||||
"bash",
|
||||
"read",
|
||||
"grep",
|
||||
"glob",
|
||||
])
|
||||
|
||||
/**
|
||||
* Tools that indicate the agent is already using delegation properly.
|
||||
*/
|
||||
const DELEGATION_TOOLS = new Set([
|
||||
"task",
|
||||
"call_omo_agent",
|
||||
])
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string
|
||||
sessionID: string
|
||||
callID: string
|
||||
agent?: string
|
||||
}
|
||||
|
||||
interface ToolExecuteOutput {
|
||||
title: string
|
||||
output: string
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
delegationUsed: boolean
|
||||
reminderShown: boolean
|
||||
toolCallCount: number
|
||||
}
|
||||
|
||||
export function createCategorySkillReminderHook(
|
||||
_ctx: PluginInput,
|
||||
availableSkills: AvailableSkill[] = []
|
||||
) {
|
||||
const sessionStates = new Map<string, SessionState>()
|
||||
const reminderMessage = buildReminderMessage(availableSkills)
|
||||
|
||||
function getOrCreateState(sessionID: string): SessionState {
|
||||
if (!sessionStates.has(sessionID)) {
|
||||
sessionStates.set(sessionID, {
|
||||
delegationUsed: false,
|
||||
reminderShown: false,
|
||||
toolCallCount: 0,
|
||||
})
|
||||
}
|
||||
return sessionStates.get(sessionID)!
|
||||
}
|
||||
|
||||
function isTargetAgent(sessionID: string, inputAgent?: string): boolean {
|
||||
const agent = getSessionAgent(sessionID) ?? inputAgent
|
||||
if (!agent) return false
|
||||
const agentLower = agent.toLowerCase()
|
||||
return (
|
||||
TARGET_AGENTS.has(agentLower) ||
|
||||
agentLower.includes("sisyphus") ||
|
||||
agentLower.includes("atlas")
|
||||
)
|
||||
}
|
||||
|
||||
const toolExecuteAfter = async (input: ToolExecuteInput, output: ToolExecuteOutput) => {
|
||||
const { tool, sessionID } = input
|
||||
const toolLower = tool.toLowerCase()
|
||||
|
||||
if (!isTargetAgent(sessionID, input.agent)) {
|
||||
return
|
||||
}
|
||||
|
||||
const state = getOrCreateState(sessionID)
|
||||
|
||||
if (DELEGATION_TOOLS.has(toolLower)) {
|
||||
state.delegationUsed = true
|
||||
log("[category-skill-reminder] Delegation tool used", { sessionID, tool })
|
||||
return
|
||||
}
|
||||
|
||||
if (!DELEGATABLE_WORK_TOOLS.has(toolLower)) {
|
||||
return
|
||||
}
|
||||
|
||||
state.toolCallCount++
|
||||
|
||||
if (state.toolCallCount >= 3 && !state.delegationUsed && !state.reminderShown) {
|
||||
output.output += reminderMessage
|
||||
state.reminderShown = true
|
||||
log("[category-skill-reminder] Reminder injected", {
|
||||
sessionID,
|
||||
toolCallCount: state.toolCallCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
sessionStates.delete(sessionInfo.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined
|
||||
if (sessionID) {
|
||||
sessionStates.delete(sessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
event: eventHandler,
|
||||
}
|
||||
}
|
||||
@ -1,177 +1 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder"
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { log } from "../../shared"
|
||||
|
||||
/**
|
||||
* Target agents that should receive category+skill reminders.
|
||||
* These are orchestrator agents that delegate work to specialized agents.
|
||||
*/
|
||||
const TARGET_AGENTS = new Set([
|
||||
"sisyphus",
|
||||
"sisyphus-junior",
|
||||
"atlas",
|
||||
])
|
||||
|
||||
/**
|
||||
* Tools that indicate the agent is doing work that could potentially be delegated.
|
||||
* When these tools are used, we remind the agent about the category+skill system.
|
||||
*/
|
||||
const DELEGATABLE_WORK_TOOLS = new Set([
|
||||
"edit",
|
||||
"write",
|
||||
"bash",
|
||||
"read",
|
||||
"grep",
|
||||
"glob",
|
||||
])
|
||||
|
||||
/**
|
||||
* Tools that indicate the agent is already using delegation properly.
|
||||
*/
|
||||
const DELEGATION_TOOLS = new Set([
|
||||
"task",
|
||||
"call_omo_agent",
|
||||
])
|
||||
|
||||
function formatSkillNames(skills: AvailableSkill[], limit: number): string {
|
||||
if (skills.length === 0) return "(none)"
|
||||
const shown = skills.slice(0, limit).map((s) => s.name)
|
||||
const remaining = skills.length - shown.length
|
||||
const suffix = remaining > 0 ? ` (+${remaining} more)` : ""
|
||||
return shown.join(", ") + suffix
|
||||
}
|
||||
|
||||
function buildReminderMessage(availableSkills: AvailableSkill[]): string {
|
||||
const builtinSkills = availableSkills.filter((s) => s.location === "plugin")
|
||||
const customSkills = availableSkills.filter((s) => s.location !== "plugin")
|
||||
|
||||
const builtinText = formatSkillNames(builtinSkills, 8)
|
||||
const customText = formatSkillNames(customSkills, 8)
|
||||
|
||||
const exampleSkillName = customSkills[0]?.name ?? builtinSkills[0]?.name
|
||||
const loadSkills = exampleSkillName ? `["${exampleSkillName}"]` : "[]"
|
||||
|
||||
const lines = [
|
||||
"",
|
||||
"[Category+Skill Reminder]",
|
||||
"",
|
||||
`**Built-in**: ${builtinText}`,
|
||||
`**⚡ YOUR SKILLS (PRIORITY)**: ${customText}`,
|
||||
"",
|
||||
"> User-installed skills OVERRIDE built-in defaults. ALWAYS prefer YOUR SKILLS when domain matches.",
|
||||
"",
|
||||
"```typescript",
|
||||
`task(category=\"visual-engineering\", load_skills=${loadSkills}, run_in_background=true)`,
|
||||
"```",
|
||||
"",
|
||||
]
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string
|
||||
sessionID: string
|
||||
callID: string
|
||||
agent?: string
|
||||
}
|
||||
|
||||
interface ToolExecuteOutput {
|
||||
title: string
|
||||
output: string
|
||||
metadata: unknown
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
delegationUsed: boolean
|
||||
reminderShown: boolean
|
||||
toolCallCount: number
|
||||
}
|
||||
|
||||
export function createCategorySkillReminderHook(
|
||||
_ctx: PluginInput,
|
||||
availableSkills: AvailableSkill[] = []
|
||||
) {
|
||||
const sessionStates = new Map<string, SessionState>()
|
||||
const reminderMessage = buildReminderMessage(availableSkills)
|
||||
|
||||
function getOrCreateState(sessionID: string): SessionState {
|
||||
if (!sessionStates.has(sessionID)) {
|
||||
sessionStates.set(sessionID, {
|
||||
delegationUsed: false,
|
||||
reminderShown: false,
|
||||
toolCallCount: 0,
|
||||
})
|
||||
}
|
||||
return sessionStates.get(sessionID)!
|
||||
}
|
||||
|
||||
function isTargetAgent(sessionID: string, inputAgent?: string): boolean {
|
||||
const agent = getSessionAgent(sessionID) ?? inputAgent
|
||||
if (!agent) return false
|
||||
const agentLower = agent.toLowerCase()
|
||||
return TARGET_AGENTS.has(agentLower) ||
|
||||
agentLower.includes("sisyphus") ||
|
||||
agentLower.includes("atlas")
|
||||
}
|
||||
|
||||
const toolExecuteAfter = async (
|
||||
input: ToolExecuteInput,
|
||||
output: ToolExecuteOutput,
|
||||
) => {
|
||||
const { tool, sessionID } = input
|
||||
const toolLower = tool.toLowerCase()
|
||||
|
||||
if (!isTargetAgent(sessionID, input.agent)) {
|
||||
return
|
||||
}
|
||||
|
||||
const state = getOrCreateState(sessionID)
|
||||
|
||||
if (DELEGATION_TOOLS.has(toolLower)) {
|
||||
state.delegationUsed = true
|
||||
log("[category-skill-reminder] Delegation tool used", { sessionID, tool })
|
||||
return
|
||||
}
|
||||
|
||||
if (!DELEGATABLE_WORK_TOOLS.has(toolLower)) {
|
||||
return
|
||||
}
|
||||
|
||||
state.toolCallCount++
|
||||
|
||||
if (state.toolCallCount >= 3 && !state.delegationUsed && !state.reminderShown) {
|
||||
output.output += reminderMessage
|
||||
state.reminderShown = true
|
||||
log("[category-skill-reminder] Reminder injected", {
|
||||
sessionID,
|
||||
toolCallCount: state.toolCallCount
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
sessionStates.delete(sessionInfo.id)
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined
|
||||
if (sessionID) {
|
||||
sessionStates.delete(sessionID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
event: eventHandler,
|
||||
}
|
||||
}
|
||||
export { createCategorySkillReminderHook } from "./hook"
|
||||
|
||||
63
src/hooks/comment-checker/cli-runner.ts
Normal file
63
src/hooks/comment-checker/cli-runner.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import type { PendingCall } from "./types"
|
||||
import { existsSync } from "fs"
|
||||
|
||||
import { runCommentChecker, getCommentCheckerPath, startBackgroundInit, type HookInput } from "./cli"
|
||||
|
||||
let cliPathPromise: Promise<string | null> | null = null
|
||||
|
||||
export function initializeCommentCheckerCli(debugLog: (...args: unknown[]) => void): void {
|
||||
// Start background CLI initialization (may trigger lazy download)
|
||||
startBackgroundInit()
|
||||
cliPathPromise = getCommentCheckerPath()
|
||||
cliPathPromise
|
||||
.then((path) => {
|
||||
debugLog("CLI path resolved:", path || "disabled (no binary)")
|
||||
})
|
||||
.catch((err) => {
|
||||
debugLog("CLI path resolution error:", err)
|
||||
})
|
||||
}
|
||||
|
||||
export function getCommentCheckerCliPathPromise(): Promise<string | null> | null {
|
||||
return cliPathPromise
|
||||
}
|
||||
|
||||
export async function processWithCli(
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
pendingCall: PendingCall,
|
||||
output: { output: string },
|
||||
cliPath: string,
|
||||
customPrompt: string | undefined,
|
||||
debugLog: (...args: unknown[]) => void,
|
||||
): Promise<void> {
|
||||
void input
|
||||
debugLog("using CLI mode with path:", cliPath)
|
||||
|
||||
const hookInput: HookInput = {
|
||||
session_id: pendingCall.sessionID,
|
||||
tool_name: pendingCall.tool.charAt(0).toUpperCase() + pendingCall.tool.slice(1),
|
||||
transcript_path: "",
|
||||
cwd: process.cwd(),
|
||||
hook_event_name: "PostToolUse",
|
||||
tool_input: {
|
||||
file_path: pendingCall.filePath,
|
||||
content: pendingCall.content,
|
||||
old_string: pendingCall.oldString,
|
||||
new_string: pendingCall.newString,
|
||||
edits: pendingCall.edits,
|
||||
},
|
||||
}
|
||||
|
||||
const result = await runCommentChecker(hookInput, cliPath, customPrompt)
|
||||
|
||||
if (result.hasComments && result.message) {
|
||||
debugLog("CLI detected comments, appending message")
|
||||
output.output += `\n\n${result.message}`
|
||||
} else {
|
||||
debugLog("CLI: no comments detected")
|
||||
}
|
||||
}
|
||||
|
||||
export function isCliPathUsable(cliPath: string | null): cliPath is string {
|
||||
return Boolean(cliPath && existsSync(cliPath))
|
||||
}
|
||||
123
src/hooks/comment-checker/hook.ts
Normal file
123
src/hooks/comment-checker/hook.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import type { PendingCall } from "./types"
|
||||
import type { CommentCheckerConfig } from "../../config/schema"
|
||||
|
||||
import { initializeCommentCheckerCli, getCommentCheckerCliPathPromise, isCliPathUsable, processWithCli } from "./cli-runner"
|
||||
import { registerPendingCall, startPendingCallCleanup, takePendingCall } from "./pending-calls"
|
||||
|
||||
import * as fs from "fs"
|
||||
import { tmpdir } from "os"
|
||||
import { join } from "path"
|
||||
|
||||
const DEBUG = process.env.COMMENT_CHECKER_DEBUG === "1"
|
||||
const DEBUG_FILE = join(tmpdir(), "comment-checker-debug.log")
|
||||
|
||||
function debugLog(...args: unknown[]) {
|
||||
if (DEBUG) {
|
||||
const msg = `[${new Date().toISOString()}] [comment-checker:hook] ${args
|
||||
.map((a) => (typeof a === "object" ? JSON.stringify(a, null, 2) : String(a)))
|
||||
.join(" ")}\n`
|
||||
fs.appendFileSync(DEBUG_FILE, msg)
|
||||
}
|
||||
}
|
||||
|
||||
export function createCommentCheckerHooks(config?: CommentCheckerConfig) {
|
||||
debugLog("createCommentCheckerHooks called", { config })
|
||||
|
||||
startPendingCallCleanup()
|
||||
initializeCommentCheckerCli(debugLog)
|
||||
|
||||
return {
|
||||
"tool.execute.before": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { args: Record<string, unknown> },
|
||||
): Promise<void> => {
|
||||
debugLog("tool.execute.before:", {
|
||||
tool: input.tool,
|
||||
callID: input.callID,
|
||||
args: output.args,
|
||||
})
|
||||
|
||||
const toolLower = input.tool.toLowerCase()
|
||||
if (toolLower !== "write" && toolLower !== "edit" && toolLower !== "multiedit") {
|
||||
debugLog("skipping non-write/edit tool:", toolLower)
|
||||
return
|
||||
}
|
||||
|
||||
const filePath = (output.args.filePath ??
|
||||
output.args.file_path ??
|
||||
output.args.path) as string | undefined
|
||||
const content = output.args.content as string | undefined
|
||||
const oldString = (output.args.oldString ?? output.args.old_string) as string | undefined
|
||||
const newString = (output.args.newString ?? output.args.new_string) as string | undefined
|
||||
const edits = output.args.edits as Array<{ old_string: string; new_string: string }> | undefined
|
||||
|
||||
debugLog("extracted filePath:", filePath)
|
||||
|
||||
if (!filePath) {
|
||||
debugLog("no filePath found")
|
||||
return
|
||||
}
|
||||
|
||||
debugLog("registering pendingCall:", {
|
||||
callID: input.callID,
|
||||
filePath,
|
||||
tool: toolLower,
|
||||
})
|
||||
registerPendingCall(input.callID, {
|
||||
filePath,
|
||||
content,
|
||||
oldString: oldString as string | undefined,
|
||||
newString: newString as string | undefined,
|
||||
edits,
|
||||
tool: toolLower as PendingCall["tool"],
|
||||
sessionID: input.sessionID,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
},
|
||||
|
||||
"tool.execute.after": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { title: string; output: string; metadata: unknown },
|
||||
): Promise<void> => {
|
||||
debugLog("tool.execute.after:", { tool: input.tool, callID: input.callID })
|
||||
|
||||
const pendingCall = takePendingCall(input.callID)
|
||||
if (!pendingCall) {
|
||||
debugLog("no pendingCall found for:", input.callID)
|
||||
return
|
||||
}
|
||||
|
||||
debugLog("processing pendingCall:", pendingCall)
|
||||
|
||||
// Only skip if the output indicates a tool execution failure
|
||||
const outputLower = output.output.toLowerCase()
|
||||
const isToolFailure =
|
||||
outputLower.includes("error:") ||
|
||||
outputLower.includes("failed to") ||
|
||||
outputLower.includes("could not") ||
|
||||
outputLower.startsWith("error")
|
||||
|
||||
if (isToolFailure) {
|
||||
debugLog("skipping due to tool failure in output")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Wait for CLI path resolution
|
||||
const cliPath = await getCommentCheckerCliPathPromise()
|
||||
|
||||
if (!isCliPathUsable(cliPath)) {
|
||||
// CLI not available - silently skip comment checking
|
||||
debugLog("CLI not available, skipping comment check")
|
||||
return
|
||||
}
|
||||
|
||||
// CLI mode only
|
||||
debugLog("using CLI:", cliPath)
|
||||
await processWithCli(input, pendingCall, output, cliPath, config?.custom_prompt, debugLog)
|
||||
} catch (err) {
|
||||
debugLog("tool.execute.after failed:", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -1,171 +1 @@
|
||||
import type { PendingCall } from "./types"
|
||||
import { runCommentChecker, getCommentCheckerPath, startBackgroundInit, type HookInput } from "./cli"
|
||||
import type { CommentCheckerConfig } from "../../config/schema"
|
||||
|
||||
import * as fs from "fs"
|
||||
import { existsSync } from "fs"
|
||||
import { tmpdir } from "os"
|
||||
import { join } from "path"
|
||||
|
||||
const DEBUG = process.env.COMMENT_CHECKER_DEBUG === "1"
|
||||
const DEBUG_FILE = join(tmpdir(), "comment-checker-debug.log")
|
||||
|
||||
function debugLog(...args: unknown[]) {
|
||||
if (DEBUG) {
|
||||
const msg = `[${new Date().toISOString()}] [comment-checker:hook] ${args.map(a => typeof a === 'object' ? JSON.stringify(a, null, 2) : String(a)).join(' ')}\n`
|
||||
fs.appendFileSync(DEBUG_FILE, msg)
|
||||
}
|
||||
}
|
||||
|
||||
const pendingCalls = new Map<string, PendingCall>()
|
||||
const PENDING_CALL_TTL = 60_000
|
||||
|
||||
let cliPathPromise: Promise<string | null> | null = null
|
||||
let cleanupIntervalStarted = false
|
||||
|
||||
function cleanupOldPendingCalls(): void {
|
||||
const now = Date.now()
|
||||
for (const [callID, call] of pendingCalls) {
|
||||
if (now - call.timestamp > PENDING_CALL_TTL) {
|
||||
pendingCalls.delete(callID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createCommentCheckerHooks(config?: CommentCheckerConfig) {
|
||||
debugLog("createCommentCheckerHooks called", { config })
|
||||
|
||||
if (!cleanupIntervalStarted) {
|
||||
cleanupIntervalStarted = true
|
||||
setInterval(cleanupOldPendingCalls, 10_000)
|
||||
}
|
||||
|
||||
// Start background CLI initialization (may trigger lazy download)
|
||||
startBackgroundInit()
|
||||
cliPathPromise = getCommentCheckerPath()
|
||||
cliPathPromise.then(path => {
|
||||
debugLog("CLI path resolved:", path || "disabled (no binary)")
|
||||
}).catch(err => {
|
||||
debugLog("CLI path resolution error:", err)
|
||||
})
|
||||
|
||||
return {
|
||||
"tool.execute.before": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { args: Record<string, unknown> }
|
||||
): Promise<void> => {
|
||||
debugLog("tool.execute.before:", { tool: input.tool, callID: input.callID, args: output.args })
|
||||
|
||||
const toolLower = input.tool.toLowerCase()
|
||||
if (toolLower !== "write" && toolLower !== "edit" && toolLower !== "multiedit") {
|
||||
debugLog("skipping non-write/edit tool:", toolLower)
|
||||
return
|
||||
}
|
||||
|
||||
const filePath = (output.args.filePath ?? output.args.file_path ?? output.args.path) as string | undefined
|
||||
const content = output.args.content as string | undefined
|
||||
const oldString = output.args.oldString ?? output.args.old_string as string | undefined
|
||||
const newString = output.args.newString ?? output.args.new_string as string | undefined
|
||||
const edits = output.args.edits as Array<{ old_string: string; new_string: string }> | undefined
|
||||
|
||||
debugLog("extracted filePath:", filePath)
|
||||
|
||||
if (!filePath) {
|
||||
debugLog("no filePath found")
|
||||
return
|
||||
}
|
||||
|
||||
debugLog("registering pendingCall:", { callID: input.callID, filePath, tool: toolLower })
|
||||
pendingCalls.set(input.callID, {
|
||||
filePath,
|
||||
content,
|
||||
oldString: oldString as string | undefined,
|
||||
newString: newString as string | undefined,
|
||||
edits,
|
||||
tool: toolLower as "write" | "edit" | "multiedit",
|
||||
sessionID: input.sessionID,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
},
|
||||
|
||||
"tool.execute.after": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { title: string; output: string; metadata: unknown }
|
||||
): Promise<void> => {
|
||||
debugLog("tool.execute.after:", { tool: input.tool, callID: input.callID })
|
||||
|
||||
const pendingCall = pendingCalls.get(input.callID)
|
||||
if (!pendingCall) {
|
||||
debugLog("no pendingCall found for:", input.callID)
|
||||
return
|
||||
}
|
||||
|
||||
pendingCalls.delete(input.callID)
|
||||
debugLog("processing pendingCall:", pendingCall)
|
||||
|
||||
// Only skip if the output indicates a tool execution failure
|
||||
const outputLower = output.output.toLowerCase()
|
||||
const isToolFailure =
|
||||
outputLower.includes("error:") ||
|
||||
outputLower.includes("failed to") ||
|
||||
outputLower.includes("could not") ||
|
||||
outputLower.startsWith("error")
|
||||
|
||||
if (isToolFailure) {
|
||||
debugLog("skipping due to tool failure in output")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Wait for CLI path resolution
|
||||
const cliPath = await cliPathPromise
|
||||
|
||||
if (!cliPath || !existsSync(cliPath)) {
|
||||
// CLI not available - silently skip comment checking
|
||||
debugLog("CLI not available, skipping comment check")
|
||||
return
|
||||
}
|
||||
|
||||
// CLI mode only
|
||||
debugLog("using CLI:", cliPath)
|
||||
await processWithCli(input, pendingCall, output, cliPath, config?.custom_prompt)
|
||||
} catch (err) {
|
||||
debugLog("tool.execute.after failed:", err)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function processWithCli(
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
pendingCall: PendingCall,
|
||||
output: { output: string },
|
||||
cliPath: string,
|
||||
customPrompt?: string
|
||||
): Promise<void> {
|
||||
debugLog("using CLI mode with path:", cliPath)
|
||||
|
||||
const hookInput: HookInput = {
|
||||
session_id: pendingCall.sessionID,
|
||||
tool_name: pendingCall.tool.charAt(0).toUpperCase() + pendingCall.tool.slice(1),
|
||||
transcript_path: "",
|
||||
cwd: process.cwd(),
|
||||
hook_event_name: "PostToolUse",
|
||||
tool_input: {
|
||||
file_path: pendingCall.filePath,
|
||||
content: pendingCall.content,
|
||||
old_string: pendingCall.oldString,
|
||||
new_string: pendingCall.newString,
|
||||
edits: pendingCall.edits,
|
||||
},
|
||||
}
|
||||
|
||||
const result = await runCommentChecker(hookInput, cliPath, customPrompt)
|
||||
|
||||
if (result.hasComments && result.message) {
|
||||
debugLog("CLI detected comments, appending message")
|
||||
output.output += `\n\n${result.message}`
|
||||
} else {
|
||||
debugLog("CLI: no comments detected")
|
||||
}
|
||||
}
|
||||
export { createCommentCheckerHooks } from "./hook"
|
||||
|
||||
32
src/hooks/comment-checker/pending-calls.ts
Normal file
32
src/hooks/comment-checker/pending-calls.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import type { PendingCall } from "./types"
|
||||
|
||||
const pendingCalls = new Map<string, PendingCall>()
|
||||
const PENDING_CALL_TTL = 60_000
|
||||
|
||||
let cleanupIntervalStarted = false
|
||||
|
||||
function cleanupOldPendingCalls(): void {
|
||||
const now = Date.now()
|
||||
for (const [callID, call] of pendingCalls) {
|
||||
if (now - call.timestamp > PENDING_CALL_TTL) {
|
||||
pendingCalls.delete(callID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function startPendingCallCleanup(): void {
|
||||
if (cleanupIntervalStarted) return
|
||||
cleanupIntervalStarted = true
|
||||
setInterval(cleanupOldPendingCalls, 10_000)
|
||||
}
|
||||
|
||||
export function registerPendingCall(callID: string, pendingCall: PendingCall): void {
|
||||
pendingCalls.set(callID, pendingCall)
|
||||
}
|
||||
|
||||
export function takePendingCall(callID: string): PendingCall | undefined {
|
||||
const pendingCall = pendingCalls.get(callID)
|
||||
if (!pendingCall) return undefined
|
||||
pendingCalls.delete(callID)
|
||||
return pendingCall
|
||||
}
|
||||
55
src/hooks/compaction-context-injector/hook.ts
Normal file
55
src/hooks/compaction-context-injector/hook.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import {
|
||||
createSystemDirective,
|
||||
SystemDirectiveTypes,
|
||||
} from "../../shared/system-directive"
|
||||
|
||||
const COMPACTION_CONTEXT_PROMPT = `${createSystemDirective(SystemDirectiveTypes.COMPACTION_CONTEXT)}
|
||||
|
||||
When summarizing this session, you MUST include the following sections in your summary:
|
||||
|
||||
## 1. User Requests (As-Is)
|
||||
- List all original user requests exactly as they were stated
|
||||
- Preserve the user's exact wording and intent
|
||||
|
||||
## 2. Final Goal
|
||||
- What the user ultimately wanted to achieve
|
||||
- The end result or deliverable expected
|
||||
|
||||
## 3. Work Completed
|
||||
- What has been done so far
|
||||
- Files created/modified
|
||||
- Features implemented
|
||||
- Problems solved
|
||||
|
||||
## 4. Remaining Tasks
|
||||
- What still needs to be done
|
||||
- Pending items from the original request
|
||||
- Follow-up tasks identified during the work
|
||||
|
||||
## 5. Active Working Context (For Seamless Continuation)
|
||||
- **Files**: Paths of files currently being edited or frequently referenced
|
||||
- **Code in Progress**: Key code snippets, function signatures, or data structures under active development
|
||||
- **External References**: Documentation URLs, library APIs, or external resources being consulted
|
||||
- **State & Variables**: Important variable names, configuration values, or runtime state relevant to ongoing work
|
||||
|
||||
## 6. Explicit Constraints (Verbatim Only)
|
||||
- Include ONLY constraints explicitly stated by the user or in existing AGENTS.md context
|
||||
- Quote constraints verbatim (do not paraphrase)
|
||||
- Do NOT invent, add, or modify constraints
|
||||
- If no explicit constraints exist, write "None"
|
||||
|
||||
## 7. Agent Verification State (Critical for Reviewers)
|
||||
- **Current Agent**: What agent is running (momus, oracle, etc.)
|
||||
- **Verification Progress**: Files already verified/validated
|
||||
- **Pending Verifications**: Files still needing verification
|
||||
- **Previous Rejections**: If reviewer agent, what was rejected and why
|
||||
- **Acceptance Status**: Current state of review process
|
||||
|
||||
This section is CRITICAL for reviewer agents (momus, oracle) to maintain continuity.
|
||||
|
||||
This context is critical for maintaining continuity after compaction.
|
||||
`
|
||||
|
||||
export function createCompactionContextInjector() {
|
||||
return (): string => COMPACTION_CONTEXT_PROMPT
|
||||
}
|
||||
@ -1,52 +1 @@
|
||||
import { createSystemDirective, SystemDirectiveTypes } from "../../shared/system-directive"
|
||||
|
||||
const COMPACTION_CONTEXT_PROMPT = `${createSystemDirective(SystemDirectiveTypes.COMPACTION_CONTEXT)}
|
||||
|
||||
When summarizing this session, you MUST include the following sections in your summary:
|
||||
|
||||
## 1. User Requests (As-Is)
|
||||
- List all original user requests exactly as they were stated
|
||||
- Preserve the user's exact wording and intent
|
||||
|
||||
## 2. Final Goal
|
||||
- What the user ultimately wanted to achieve
|
||||
- The end result or deliverable expected
|
||||
|
||||
## 3. Work Completed
|
||||
- What has been done so far
|
||||
- Files created/modified
|
||||
- Features implemented
|
||||
- Problems solved
|
||||
|
||||
## 4. Remaining Tasks
|
||||
- What still needs to be done
|
||||
- Pending items from the original request
|
||||
- Follow-up tasks identified during the work
|
||||
|
||||
## 5. Active Working Context (For Seamless Continuation)
|
||||
- **Files**: Paths of files currently being edited or frequently referenced
|
||||
- **Code in Progress**: Key code snippets, function signatures, or data structures under active development
|
||||
- **External References**: Documentation URLs, library APIs, or external resources being consulted
|
||||
- **State & Variables**: Important variable names, configuration values, or runtime state relevant to ongoing work
|
||||
|
||||
## 6. Explicit Constraints (Verbatim Only)
|
||||
- Include ONLY constraints explicitly stated by the user or in existing AGENTS.md context
|
||||
- Quote constraints verbatim (do not paraphrase)
|
||||
- Do NOT invent, add, or modify constraints
|
||||
- If no explicit constraints exist, write "None"
|
||||
|
||||
## 7. Agent Verification State (Critical for Reviewers)
|
||||
- **Current Agent**: What agent is running (momus, oracle, etc.)
|
||||
- **Verification Progress**: Files already verified/validated
|
||||
- **Pending Verifications**: Files still needing verification
|
||||
- **Previous Rejections**: If reviewer agent, what was rejected and why
|
||||
- **Acceptance Status**: Current state of review process
|
||||
|
||||
This section is CRITICAL for reviewer agents (momus, oracle) to maintain continuity.
|
||||
|
||||
This context is critical for maintaining continuity after compaction.
|
||||
`
|
||||
|
||||
export function createCompactionContextInjector() {
|
||||
return (): string => COMPACTION_CONTEXT_PROMPT
|
||||
}
|
||||
export { createCompactionContextInjector } from "./hook"
|
||||
|
||||
127
src/hooks/compaction-todo-preserver/hook.ts
Normal file
127
src/hooks/compaction-todo-preserver/hook.ts
Normal file
@ -0,0 +1,127 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
interface TodoSnapshot {
|
||||
id: string
|
||||
content: string
|
||||
status: "pending" | "in_progress" | "completed" | "cancelled"
|
||||
priority?: "low" | "medium" | "high"
|
||||
}
|
||||
|
||||
type TodoWriter = (input: { sessionID: string; todos: TodoSnapshot[] }) => Promise<void>
|
||||
|
||||
const HOOK_NAME = "compaction-todo-preserver"
|
||||
|
||||
function extractTodos(response: unknown): TodoSnapshot[] {
|
||||
const payload = response as { data?: unknown }
|
||||
if (Array.isArray(payload?.data)) {
|
||||
return payload.data as TodoSnapshot[]
|
||||
}
|
||||
if (Array.isArray(response)) {
|
||||
return response as TodoSnapshot[]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
async function resolveTodoWriter(): Promise<TodoWriter | null> {
|
||||
try {
|
||||
const loader = "opencode/session/todo"
|
||||
const mod = (await import(loader)) as {
|
||||
Todo?: { update?: TodoWriter }
|
||||
}
|
||||
const update = mod.Todo?.update
|
||||
if (typeof update === "function") {
|
||||
return update
|
||||
}
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Failed to resolve Todo.update`, { error: String(err) })
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveSessionID(props?: Record<string, unknown>): string | undefined {
|
||||
return (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined
|
||||
}
|
||||
|
||||
export interface CompactionTodoPreserver {
|
||||
capture: (sessionID: string) => Promise<void>
|
||||
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
|
||||
}
|
||||
|
||||
export function createCompactionTodoPreserverHook(
|
||||
ctx: PluginInput,
|
||||
): CompactionTodoPreserver {
|
||||
const snapshots = new Map<string, TodoSnapshot[]>()
|
||||
|
||||
const capture = async (sessionID: string): Promise<void> => {
|
||||
if (!sessionID) return
|
||||
try {
|
||||
const response = await ctx.client.session.todo({ path: { id: sessionID } })
|
||||
const todos = extractTodos(response)
|
||||
if (todos.length === 0) return
|
||||
snapshots.set(sessionID, todos)
|
||||
log(`[${HOOK_NAME}] Captured todo snapshot`, { sessionID, count: todos.length })
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Failed to capture todos`, { sessionID, error: String(err) })
|
||||
}
|
||||
}
|
||||
|
||||
const restore = async (sessionID: string): Promise<void> => {
|
||||
const snapshot = snapshots.get(sessionID)
|
||||
if (!snapshot || snapshot.length === 0) return
|
||||
|
||||
let hasCurrent = false
|
||||
let currentTodos: TodoSnapshot[] = []
|
||||
try {
|
||||
const response = await ctx.client.session.todo({ path: { id: sessionID } })
|
||||
currentTodos = extractTodos(response)
|
||||
hasCurrent = true
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Failed to fetch todos post-compaction`, { sessionID, error: String(err) })
|
||||
}
|
||||
|
||||
if (hasCurrent && currentTodos.length > 0) {
|
||||
snapshots.delete(sessionID)
|
||||
log(`[${HOOK_NAME}] Skipped restore (todos already present)`, { sessionID, count: currentTodos.length })
|
||||
return
|
||||
}
|
||||
|
||||
const writer = await resolveTodoWriter()
|
||||
if (!writer) {
|
||||
log(`[${HOOK_NAME}] Skipped restore (Todo.update unavailable)`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await writer({ sessionID, todos: snapshot })
|
||||
log(`[${HOOK_NAME}] Restored todos after compaction`, { sessionID, count: snapshot.length })
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Failed to restore todos`, { sessionID, error: String(err) })
|
||||
} finally {
|
||||
snapshots.delete(sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
const event = async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionID = resolveSessionID(props)
|
||||
if (sessionID) {
|
||||
snapshots.delete(sessionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = resolveSessionID(props)
|
||||
if (sessionID) {
|
||||
await restore(sessionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return { capture, event }
|
||||
}
|
||||
@ -1,127 +1,2 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
interface TodoSnapshot {
|
||||
id: string
|
||||
content: string
|
||||
status: "pending" | "in_progress" | "completed" | "cancelled"
|
||||
priority?: "low" | "medium" | "high"
|
||||
}
|
||||
|
||||
type TodoWriter = (input: { sessionID: string; todos: TodoSnapshot[] }) => Promise<void>
|
||||
|
||||
const HOOK_NAME = "compaction-todo-preserver"
|
||||
|
||||
function extractTodos(response: unknown): TodoSnapshot[] {
|
||||
const payload = response as { data?: unknown }
|
||||
if (Array.isArray(payload?.data)) {
|
||||
return payload.data as TodoSnapshot[]
|
||||
}
|
||||
if (Array.isArray(response)) {
|
||||
return response as TodoSnapshot[]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
async function resolveTodoWriter(): Promise<TodoWriter | null> {
|
||||
try {
|
||||
const loader = "opencode/session/todo"
|
||||
const mod = (await import(loader)) as {
|
||||
Todo?: { update?: TodoWriter }
|
||||
}
|
||||
const update = mod.Todo?.update
|
||||
if (typeof update === "function") {
|
||||
return update
|
||||
}
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Failed to resolve Todo.update`, { error: String(err) })
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveSessionID(props?: Record<string, unknown>): string | undefined {
|
||||
return (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined
|
||||
}
|
||||
|
||||
export interface CompactionTodoPreserver {
|
||||
capture: (sessionID: string) => Promise<void>
|
||||
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
|
||||
}
|
||||
|
||||
export function createCompactionTodoPreserverHook(
|
||||
ctx: PluginInput,
|
||||
): CompactionTodoPreserver {
|
||||
const snapshots = new Map<string, TodoSnapshot[]>()
|
||||
|
||||
const capture = async (sessionID: string): Promise<void> => {
|
||||
if (!sessionID) return
|
||||
try {
|
||||
const response = await ctx.client.session.todo({ path: { id: sessionID } })
|
||||
const todos = extractTodos(response)
|
||||
if (todos.length === 0) return
|
||||
snapshots.set(sessionID, todos)
|
||||
log(`[${HOOK_NAME}] Captured todo snapshot`, { sessionID, count: todos.length })
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Failed to capture todos`, { sessionID, error: String(err) })
|
||||
}
|
||||
}
|
||||
|
||||
const restore = async (sessionID: string): Promise<void> => {
|
||||
const snapshot = snapshots.get(sessionID)
|
||||
if (!snapshot || snapshot.length === 0) return
|
||||
|
||||
let hasCurrent = false
|
||||
let currentTodos: TodoSnapshot[] = []
|
||||
try {
|
||||
const response = await ctx.client.session.todo({ path: { id: sessionID } })
|
||||
currentTodos = extractTodos(response)
|
||||
hasCurrent = true
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Failed to fetch todos post-compaction`, { sessionID, error: String(err) })
|
||||
}
|
||||
|
||||
if (hasCurrent && currentTodos.length > 0) {
|
||||
snapshots.delete(sessionID)
|
||||
log(`[${HOOK_NAME}] Skipped restore (todos already present)`, { sessionID, count: currentTodos.length })
|
||||
return
|
||||
}
|
||||
|
||||
const writer = await resolveTodoWriter()
|
||||
if (!writer) {
|
||||
log(`[${HOOK_NAME}] Skipped restore (Todo.update unavailable)`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await writer({ sessionID, todos: snapshot })
|
||||
log(`[${HOOK_NAME}] Restored todos after compaction`, { sessionID, count: snapshot.length })
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Failed to restore todos`, { sessionID, error: String(err) })
|
||||
} finally {
|
||||
snapshots.delete(sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
const event = async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionID = resolveSessionID(props)
|
||||
if (sessionID) {
|
||||
snapshots.delete(sessionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = resolveSessionID(props)
|
||||
if (sessionID) {
|
||||
await restore(sessionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return { capture, event }
|
||||
}
|
||||
export type { CompactionTodoPreserver } from "./hook"
|
||||
export { createCompactionTodoPreserverHook } from "./hook"
|
||||
|
||||
45
src/hooks/delegate-task-retry/guidance.ts
Normal file
45
src/hooks/delegate-task-retry/guidance.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { DELEGATE_TASK_ERROR_PATTERNS, type DetectedError } from "./patterns"
|
||||
|
||||
function extractAvailableList(output: string): string | null {
|
||||
const availableMatch = output.match(/Available[^:]*:\s*(.+)$/m)
|
||||
return availableMatch ? availableMatch[1].trim() : null
|
||||
}
|
||||
|
||||
export function buildRetryGuidance(errorInfo: DetectedError): string {
|
||||
const pattern = DELEGATE_TASK_ERROR_PATTERNS.find(
|
||||
(p) => p.errorType === errorInfo.errorType
|
||||
)
|
||||
|
||||
if (!pattern) {
|
||||
return `[task ERROR] Fix the error and retry with correct parameters.`
|
||||
}
|
||||
|
||||
let guidance = `
|
||||
[task CALL FAILED - IMMEDIATE RETRY REQUIRED]
|
||||
|
||||
**Error Type**: ${errorInfo.errorType}
|
||||
**Fix**: ${pattern.fixHint}
|
||||
`
|
||||
|
||||
const availableList = extractAvailableList(errorInfo.originalOutput)
|
||||
if (availableList) {
|
||||
guidance += `\n**Available Options**: ${availableList}\n`
|
||||
}
|
||||
|
||||
guidance += `
|
||||
**Action**: Retry task NOW with corrected parameters.
|
||||
|
||||
Example of CORRECT call:
|
||||
\`\`\`
|
||||
task(
|
||||
description="Task description",
|
||||
prompt="Detailed prompt...",
|
||||
category="unspecified-low", // OR subagent_type="explore"
|
||||
run_in_background=false,
|
||||
load_skills=[]
|
||||
)
|
||||
\`\`\`
|
||||
`
|
||||
|
||||
return guidance
|
||||
}
|
||||
21
src/hooks/delegate-task-retry/hook.ts
Normal file
21
src/hooks/delegate-task-retry/hook.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import { buildRetryGuidance } from "./guidance"
|
||||
import { detectDelegateTaskError } from "./patterns"
|
||||
|
||||
export function createDelegateTaskRetryHook(_ctx: PluginInput) {
|
||||
return {
|
||||
"tool.execute.after": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { title: string; output: string; metadata: unknown }
|
||||
) => {
|
||||
if (input.tool.toLowerCase() !== "task") return
|
||||
|
||||
const errorInfo = detectDelegateTaskError(output.output)
|
||||
if (errorInfo) {
|
||||
const guidance = buildRetryGuidance(errorInfo)
|
||||
output.output += `\n${guidance}`
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -1,136 +1,4 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
export interface DelegateTaskErrorPattern {
|
||||
pattern: string
|
||||
errorType: string
|
||||
fixHint: string
|
||||
}
|
||||
|
||||
export const DELEGATE_TASK_ERROR_PATTERNS: DelegateTaskErrorPattern[] = [
|
||||
{
|
||||
pattern: "run_in_background",
|
||||
errorType: "missing_run_in_background",
|
||||
fixHint: "Add run_in_background=false (for delegation) or run_in_background=true (for parallel exploration)",
|
||||
},
|
||||
{
|
||||
pattern: "load_skills",
|
||||
errorType: "missing_load_skills",
|
||||
fixHint: "Add load_skills=[] parameter (empty array if no skills needed). Note: Calling Skill tool does NOT populate this.",
|
||||
},
|
||||
{
|
||||
pattern: "category OR subagent_type",
|
||||
errorType: "mutual_exclusion",
|
||||
fixHint: "Provide ONLY one of: category (e.g., 'general', 'quick') OR subagent_type (e.g., 'oracle', 'explore')",
|
||||
},
|
||||
{
|
||||
pattern: "Must provide either category or subagent_type",
|
||||
errorType: "missing_category_or_agent",
|
||||
fixHint: "Add either category='general' OR subagent_type='explore'",
|
||||
},
|
||||
{
|
||||
pattern: "Unknown category",
|
||||
errorType: "unknown_category",
|
||||
fixHint: "Use a valid category from the Available list in the error message",
|
||||
},
|
||||
{
|
||||
pattern: "Agent name cannot be empty",
|
||||
errorType: "empty_agent",
|
||||
fixHint: "Provide a non-empty subagent_type value",
|
||||
},
|
||||
{
|
||||
pattern: "Unknown agent",
|
||||
errorType: "unknown_agent",
|
||||
fixHint: "Use a valid agent from the Available agents list in the error message",
|
||||
},
|
||||
{
|
||||
pattern: "Cannot call primary agent",
|
||||
errorType: "primary_agent",
|
||||
fixHint: "Primary agents cannot be called via task. Use a subagent like 'explore', 'oracle', or 'librarian'",
|
||||
},
|
||||
{
|
||||
pattern: "Skills not found",
|
||||
errorType: "unknown_skills",
|
||||
fixHint: "Use valid skill names from the Available list in the error message",
|
||||
},
|
||||
]
|
||||
|
||||
export interface DetectedError {
|
||||
errorType: string
|
||||
originalOutput: string
|
||||
}
|
||||
|
||||
export function detectDelegateTaskError(output: string): DetectedError | null {
|
||||
if (!output.includes("[ERROR]") && !output.includes("Invalid arguments")) return null
|
||||
|
||||
for (const errorPattern of DELEGATE_TASK_ERROR_PATTERNS) {
|
||||
if (output.includes(errorPattern.pattern)) {
|
||||
return {
|
||||
errorType: errorPattern.errorType,
|
||||
originalOutput: output,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractAvailableList(output: string): string | null {
|
||||
const availableMatch = output.match(/Available[^:]*:\s*(.+)$/m)
|
||||
return availableMatch ? availableMatch[1].trim() : null
|
||||
}
|
||||
|
||||
export function buildRetryGuidance(errorInfo: DetectedError): string {
|
||||
const pattern = DELEGATE_TASK_ERROR_PATTERNS.find(
|
||||
(p) => p.errorType === errorInfo.errorType
|
||||
)
|
||||
|
||||
if (!pattern) {
|
||||
return `[task ERROR] Fix the error and retry with correct parameters.`
|
||||
}
|
||||
|
||||
let guidance = `
|
||||
[task CALL FAILED - IMMEDIATE RETRY REQUIRED]
|
||||
|
||||
**Error Type**: ${errorInfo.errorType}
|
||||
**Fix**: ${pattern.fixHint}
|
||||
`
|
||||
|
||||
const availableList = extractAvailableList(errorInfo.originalOutput)
|
||||
if (availableList) {
|
||||
guidance += `\n**Available Options**: ${availableList}\n`
|
||||
}
|
||||
|
||||
guidance += `
|
||||
**Action**: Retry task NOW with corrected parameters.
|
||||
|
||||
Example of CORRECT call:
|
||||
\`\`\`
|
||||
task(
|
||||
description="Task description",
|
||||
prompt="Detailed prompt...",
|
||||
category="unspecified-low", // OR subagent_type="explore"
|
||||
run_in_background=false,
|
||||
load_skills=[]
|
||||
)
|
||||
\`\`\`
|
||||
`
|
||||
|
||||
return guidance
|
||||
}
|
||||
|
||||
export function createDelegateTaskRetryHook(_ctx: PluginInput) {
|
||||
return {
|
||||
"tool.execute.after": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { title: string; output: string; metadata: unknown }
|
||||
) => {
|
||||
if (input.tool.toLowerCase() !== "task") return
|
||||
|
||||
const errorInfo = detectDelegateTaskError(output.output)
|
||||
if (errorInfo) {
|
||||
const guidance = buildRetryGuidance(errorInfo)
|
||||
output.output += `\n${guidance}`
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
export type { DelegateTaskErrorPattern, DetectedError } from "./patterns"
|
||||
export { DELEGATE_TASK_ERROR_PATTERNS, detectDelegateTaskError } from "./patterns"
|
||||
export { buildRetryGuidance } from "./guidance"
|
||||
export { createDelegateTaskRetryHook } from "./hook"
|
||||
|
||||
77
src/hooks/delegate-task-retry/patterns.ts
Normal file
77
src/hooks/delegate-task-retry/patterns.ts
Normal file
@ -0,0 +1,77 @@
|
||||
export interface DelegateTaskErrorPattern {
|
||||
pattern: string
|
||||
errorType: string
|
||||
fixHint: string
|
||||
}
|
||||
|
||||
export const DELEGATE_TASK_ERROR_PATTERNS: DelegateTaskErrorPattern[] = [
|
||||
{
|
||||
pattern: "run_in_background",
|
||||
errorType: "missing_run_in_background",
|
||||
fixHint:
|
||||
"Add run_in_background=false (for delegation) or run_in_background=true (for parallel exploration)",
|
||||
},
|
||||
{
|
||||
pattern: "load_skills",
|
||||
errorType: "missing_load_skills",
|
||||
fixHint:
|
||||
"Add load_skills=[] parameter (empty array if no skills needed). Note: Calling Skill tool does NOT populate this.",
|
||||
},
|
||||
{
|
||||
pattern: "category OR subagent_type",
|
||||
errorType: "mutual_exclusion",
|
||||
fixHint:
|
||||
"Provide ONLY one of: category (e.g., 'general', 'quick') OR subagent_type (e.g., 'oracle', 'explore')",
|
||||
},
|
||||
{
|
||||
pattern: "Must provide either category or subagent_type",
|
||||
errorType: "missing_category_or_agent",
|
||||
fixHint: "Add either category='general' OR subagent_type='explore'",
|
||||
},
|
||||
{
|
||||
pattern: "Unknown category",
|
||||
errorType: "unknown_category",
|
||||
fixHint: "Use a valid category from the Available list in the error message",
|
||||
},
|
||||
{
|
||||
pattern: "Agent name cannot be empty",
|
||||
errorType: "empty_agent",
|
||||
fixHint: "Provide a non-empty subagent_type value",
|
||||
},
|
||||
{
|
||||
pattern: "Unknown agent",
|
||||
errorType: "unknown_agent",
|
||||
fixHint: "Use a valid agent from the Available agents list in the error message",
|
||||
},
|
||||
{
|
||||
pattern: "Cannot call primary agent",
|
||||
errorType: "primary_agent",
|
||||
fixHint:
|
||||
"Primary agents cannot be called via task. Use a subagent like 'explore', 'oracle', or 'librarian'",
|
||||
},
|
||||
{
|
||||
pattern: "Skills not found",
|
||||
errorType: "unknown_skills",
|
||||
fixHint: "Use valid skill names from the Available list in the error message",
|
||||
},
|
||||
]
|
||||
|
||||
export interface DetectedError {
|
||||
errorType: string
|
||||
originalOutput: string
|
||||
}
|
||||
|
||||
export function detectDelegateTaskError(output: string): DetectedError | null {
|
||||
if (!output.includes("[ERROR]") && !output.includes("Invalid arguments")) return null
|
||||
|
||||
for (const errorPattern of DELEGATE_TASK_ERROR_PATTERNS) {
|
||||
if (output.includes(errorPattern.pattern)) {
|
||||
return {
|
||||
errorType: errorPattern.errorType,
|
||||
originalOutput: output,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
38
src/hooks/directory-agents-injector/finder.ts
Normal file
38
src/hooks/directory-agents-injector/finder.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
import { AGENTS_FILENAME } from "./constants";
|
||||
|
||||
export function resolveFilePath(rootDirectory: string, path: string): string | null {
|
||||
if (!path) return null;
|
||||
if (path.startsWith("/")) return path;
|
||||
return resolve(rootDirectory, path);
|
||||
}
|
||||
|
||||
export function findAgentsMdUp(input: {
|
||||
startDir: string;
|
||||
rootDir: string;
|
||||
}): string[] {
|
||||
const found: string[] = [];
|
||||
let current = input.startDir;
|
||||
|
||||
while (true) {
|
||||
// Skip root AGENTS.md - OpenCode's system.ts already loads it via custom()
|
||||
// See: https://github.com/code-yeongyu/oh-my-opencode/issues/379
|
||||
const isRootDir = current === input.rootDir;
|
||||
if (!isRootDir) {
|
||||
const agentsPath = join(current, AGENTS_FILENAME);
|
||||
if (existsSync(agentsPath)) {
|
||||
found.push(agentsPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (isRootDir) break;
|
||||
const parent = dirname(current);
|
||||
if (parent === current) break;
|
||||
if (!parent.startsWith(input.rootDir)) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return found.reverse();
|
||||
}
|
||||
84
src/hooks/directory-agents-injector/hook.ts
Normal file
84
src/hooks/directory-agents-injector/hook.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
|
||||
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
||||
import { processFilePathForAgentsInjection } from "./injector";
|
||||
import { clearInjectedPaths } from "./storage";
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string;
|
||||
sessionID: string;
|
||||
callID: string;
|
||||
}
|
||||
|
||||
interface ToolExecuteOutput {
|
||||
title: string;
|
||||
output: string;
|
||||
metadata: unknown;
|
||||
}
|
||||
|
||||
interface ToolExecuteBeforeOutput {
|
||||
args: unknown;
|
||||
}
|
||||
|
||||
interface EventInput {
|
||||
event: {
|
||||
type: string;
|
||||
properties?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export function createDirectoryAgentsInjectorHook(ctx: PluginInput) {
|
||||
const sessionCaches = new Map<string, Set<string>>();
|
||||
const truncator = createDynamicTruncator(ctx);
|
||||
|
||||
const toolExecuteAfter = async (input: ToolExecuteInput, output: ToolExecuteOutput) => {
|
||||
const toolName = input.tool.toLowerCase();
|
||||
|
||||
if (toolName === "read") {
|
||||
await processFilePathForAgentsInjection({
|
||||
ctx,
|
||||
truncator,
|
||||
sessionCaches,
|
||||
filePath: output.title,
|
||||
sessionID: input.sessionID,
|
||||
output,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const toolExecuteBefore = async (
|
||||
input: ToolExecuteInput,
|
||||
output: ToolExecuteBeforeOutput,
|
||||
): Promise<void> => {
|
||||
void input;
|
||||
void output;
|
||||
};
|
||||
|
||||
const eventHandler = async ({ event }: EventInput) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
if (sessionInfo?.id) {
|
||||
sessionCaches.delete(sessionInfo.id);
|
||||
clearInjectedPaths(sessionInfo.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
||||
if (sessionID) {
|
||||
sessionCaches.delete(sessionID);
|
||||
clearInjectedPaths(sessionID);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
"tool.execute.before": toolExecuteBefore,
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
event: eventHandler,
|
||||
};
|
||||
}
|
||||
@ -1,153 +1 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import {
|
||||
loadInjectedPaths,
|
||||
saveInjectedPaths,
|
||||
clearInjectedPaths,
|
||||
} from "./storage";
|
||||
import { AGENTS_FILENAME } from "./constants";
|
||||
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string;
|
||||
sessionID: string;
|
||||
callID: string;
|
||||
}
|
||||
|
||||
interface ToolExecuteOutput {
|
||||
title: string;
|
||||
output: string;
|
||||
metadata: unknown;
|
||||
}
|
||||
|
||||
interface ToolExecuteBeforeOutput {
|
||||
args: unknown;
|
||||
}
|
||||
|
||||
interface EventInput {
|
||||
event: {
|
||||
type: string;
|
||||
properties?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export function createDirectoryAgentsInjectorHook(ctx: PluginInput) {
|
||||
const sessionCaches = new Map<string, Set<string>>();
|
||||
const truncator = createDynamicTruncator(ctx);
|
||||
|
||||
function getSessionCache(sessionID: string): Set<string> {
|
||||
if (!sessionCaches.has(sessionID)) {
|
||||
sessionCaches.set(sessionID, loadInjectedPaths(sessionID));
|
||||
}
|
||||
return sessionCaches.get(sessionID)!;
|
||||
}
|
||||
|
||||
function resolveFilePath(path: string): string | null {
|
||||
if (!path) return null;
|
||||
if (path.startsWith("/")) return path;
|
||||
return resolve(ctx.directory, path);
|
||||
}
|
||||
|
||||
function findAgentsMdUp(startDir: string): string[] {
|
||||
const found: string[] = [];
|
||||
let current = startDir;
|
||||
|
||||
while (true) {
|
||||
// Skip root AGENTS.md - OpenCode's system.ts already loads it via custom()
|
||||
// See: https://github.com/code-yeongyu/oh-my-opencode/issues/379
|
||||
const isRootDir = current === ctx.directory;
|
||||
if (!isRootDir) {
|
||||
const agentsPath = join(current, AGENTS_FILENAME);
|
||||
if (existsSync(agentsPath)) {
|
||||
found.push(agentsPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (isRootDir) break;
|
||||
const parent = dirname(current);
|
||||
if (parent === current) break;
|
||||
if (!parent.startsWith(ctx.directory)) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return found.reverse();
|
||||
}
|
||||
|
||||
async function processFilePathForInjection(
|
||||
filePath: string,
|
||||
sessionID: string,
|
||||
output: ToolExecuteOutput,
|
||||
): Promise<void> {
|
||||
const resolved = resolveFilePath(filePath);
|
||||
if (!resolved) return;
|
||||
|
||||
const dir = dirname(resolved);
|
||||
const cache = getSessionCache(sessionID);
|
||||
const agentsPaths = findAgentsMdUp(dir);
|
||||
|
||||
for (const agentsPath of agentsPaths) {
|
||||
const agentsDir = dirname(agentsPath);
|
||||
if (cache.has(agentsDir)) continue;
|
||||
|
||||
try {
|
||||
const content = readFileSync(agentsPath, "utf-8");
|
||||
const { result, truncated } = await truncator.truncate(sessionID, content);
|
||||
const truncationNotice = truncated
|
||||
? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${agentsPath}]`
|
||||
: "";
|
||||
output.output += `\n\n[Directory Context: ${agentsPath}]\n${result}${truncationNotice}`;
|
||||
cache.add(agentsDir);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
saveInjectedPaths(sessionID, cache);
|
||||
}
|
||||
|
||||
const toolExecuteAfter = async (
|
||||
input: ToolExecuteInput,
|
||||
output: ToolExecuteOutput,
|
||||
) => {
|
||||
const toolName = input.tool.toLowerCase();
|
||||
|
||||
if (toolName === "read") {
|
||||
await processFilePathForInjection(output.title, input.sessionID, output);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const toolExecuteBefore = async (
|
||||
input: ToolExecuteInput,
|
||||
output: ToolExecuteBeforeOutput,
|
||||
): Promise<void> => {
|
||||
void input;
|
||||
void output;
|
||||
};
|
||||
|
||||
const eventHandler = async ({ event }: EventInput) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
if (sessionInfo?.id) {
|
||||
sessionCaches.delete(sessionInfo.id);
|
||||
clearInjectedPaths(sessionInfo.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
||||
if (sessionID) {
|
||||
sessionCaches.delete(sessionID);
|
||||
clearInjectedPaths(sessionID);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
"tool.execute.before": toolExecuteBefore,
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
event: eventHandler,
|
||||
};
|
||||
}
|
||||
export { createDirectoryAgentsInjectorHook } from "./hook";
|
||||
|
||||
55
src/hooks/directory-agents-injector/injector.ts
Normal file
55
src/hooks/directory-agents-injector/injector.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
import type { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
||||
import { findAgentsMdUp, resolveFilePath } from "./finder";
|
||||
import { loadInjectedPaths, saveInjectedPaths } from "./storage";
|
||||
|
||||
type DynamicTruncator = ReturnType<typeof createDynamicTruncator>;
|
||||
|
||||
function getSessionCache(
|
||||
sessionCaches: Map<string, Set<string>>,
|
||||
sessionID: string,
|
||||
): Set<string> {
|
||||
if (!sessionCaches.has(sessionID)) {
|
||||
sessionCaches.set(sessionID, loadInjectedPaths(sessionID));
|
||||
}
|
||||
return sessionCaches.get(sessionID)!;
|
||||
}
|
||||
|
||||
export async function processFilePathForAgentsInjection(input: {
|
||||
ctx: PluginInput;
|
||||
truncator: DynamicTruncator;
|
||||
sessionCaches: Map<string, Set<string>>;
|
||||
filePath: string;
|
||||
sessionID: string;
|
||||
output: { title: string; output: string; metadata: unknown };
|
||||
}): Promise<void> {
|
||||
const resolved = resolveFilePath(input.ctx.directory, input.filePath);
|
||||
if (!resolved) return;
|
||||
|
||||
const dir = dirname(resolved);
|
||||
const cache = getSessionCache(input.sessionCaches, input.sessionID);
|
||||
const agentsPaths = findAgentsMdUp({ startDir: dir, rootDir: input.ctx.directory });
|
||||
|
||||
for (const agentsPath of agentsPaths) {
|
||||
const agentsDir = dirname(agentsPath);
|
||||
if (cache.has(agentsDir)) continue;
|
||||
|
||||
try {
|
||||
const content = readFileSync(agentsPath, "utf-8");
|
||||
const { result, truncated } = await input.truncator.truncate(
|
||||
input.sessionID,
|
||||
content,
|
||||
);
|
||||
const truncationNotice = truncated
|
||||
? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${agentsPath}]`
|
||||
: "";
|
||||
input.output.output += `\n\n[Directory Context: ${agentsPath}]\n${result}${truncationNotice}`;
|
||||
cache.add(agentsDir);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
saveInjectedPaths(input.sessionID, cache);
|
||||
}
|
||||
33
src/hooks/directory-readme-injector/finder.ts
Normal file
33
src/hooks/directory-readme-injector/finder.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
|
||||
import { README_FILENAME } from "./constants";
|
||||
|
||||
export function resolveFilePath(rootDirectory: string, path: string): string | null {
|
||||
if (!path) return null;
|
||||
if (path.startsWith("/")) return path;
|
||||
return resolve(rootDirectory, path);
|
||||
}
|
||||
|
||||
export function findReadmeMdUp(input: {
|
||||
startDir: string;
|
||||
rootDir: string;
|
||||
}): string[] {
|
||||
const found: string[] = [];
|
||||
let current = input.startDir;
|
||||
|
||||
while (true) {
|
||||
const readmePath = join(current, README_FILENAME);
|
||||
if (existsSync(readmePath)) {
|
||||
found.push(readmePath);
|
||||
}
|
||||
|
||||
if (current === input.rootDir) break;
|
||||
const parent = dirname(current);
|
||||
if (parent === current) break;
|
||||
if (!parent.startsWith(input.rootDir)) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return found.reverse();
|
||||
}
|
||||
84
src/hooks/directory-readme-injector/hook.ts
Normal file
84
src/hooks/directory-readme-injector/hook.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
|
||||
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
||||
import { processFilePathForReadmeInjection } from "./injector";
|
||||
import { clearInjectedPaths } from "./storage";
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string;
|
||||
sessionID: string;
|
||||
callID: string;
|
||||
}
|
||||
|
||||
interface ToolExecuteOutput {
|
||||
title: string;
|
||||
output: string;
|
||||
metadata: unknown;
|
||||
}
|
||||
|
||||
interface ToolExecuteBeforeOutput {
|
||||
args: unknown;
|
||||
}
|
||||
|
||||
interface EventInput {
|
||||
event: {
|
||||
type: string;
|
||||
properties?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export function createDirectoryReadmeInjectorHook(ctx: PluginInput) {
|
||||
const sessionCaches = new Map<string, Set<string>>();
|
||||
const truncator = createDynamicTruncator(ctx);
|
||||
|
||||
const toolExecuteAfter = async (input: ToolExecuteInput, output: ToolExecuteOutput) => {
|
||||
const toolName = input.tool.toLowerCase();
|
||||
|
||||
if (toolName === "read") {
|
||||
await processFilePathForReadmeInjection({
|
||||
ctx,
|
||||
truncator,
|
||||
sessionCaches,
|
||||
filePath: output.title,
|
||||
sessionID: input.sessionID,
|
||||
output,
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const toolExecuteBefore = async (
|
||||
input: ToolExecuteInput,
|
||||
output: ToolExecuteBeforeOutput,
|
||||
): Promise<void> => {
|
||||
void input;
|
||||
void output;
|
||||
};
|
||||
|
||||
const eventHandler = async ({ event }: EventInput) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
if (sessionInfo?.id) {
|
||||
sessionCaches.delete(sessionInfo.id);
|
||||
clearInjectedPaths(sessionInfo.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
||||
if (sessionID) {
|
||||
sessionCaches.delete(sessionID);
|
||||
clearInjectedPaths(sessionID);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
"tool.execute.before": toolExecuteBefore,
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
event: eventHandler,
|
||||
};
|
||||
}
|
||||
@ -1,148 +1 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import {
|
||||
loadInjectedPaths,
|
||||
saveInjectedPaths,
|
||||
clearInjectedPaths,
|
||||
} from "./storage";
|
||||
import { README_FILENAME } from "./constants";
|
||||
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string;
|
||||
sessionID: string;
|
||||
callID: string;
|
||||
}
|
||||
|
||||
interface ToolExecuteOutput {
|
||||
title: string;
|
||||
output: string;
|
||||
metadata: unknown;
|
||||
}
|
||||
|
||||
interface ToolExecuteBeforeOutput {
|
||||
args: unknown;
|
||||
}
|
||||
|
||||
interface EventInput {
|
||||
event: {
|
||||
type: string;
|
||||
properties?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export function createDirectoryReadmeInjectorHook(ctx: PluginInput) {
|
||||
const sessionCaches = new Map<string, Set<string>>();
|
||||
const truncator = createDynamicTruncator(ctx);
|
||||
|
||||
function getSessionCache(sessionID: string): Set<string> {
|
||||
if (!sessionCaches.has(sessionID)) {
|
||||
sessionCaches.set(sessionID, loadInjectedPaths(sessionID));
|
||||
}
|
||||
return sessionCaches.get(sessionID)!;
|
||||
}
|
||||
|
||||
function resolveFilePath(path: string): string | null {
|
||||
if (!path) return null;
|
||||
if (path.startsWith("/")) return path;
|
||||
return resolve(ctx.directory, path);
|
||||
}
|
||||
|
||||
function findReadmeMdUp(startDir: string): string[] {
|
||||
const found: string[] = [];
|
||||
let current = startDir;
|
||||
|
||||
while (true) {
|
||||
const readmePath = join(current, README_FILENAME);
|
||||
if (existsSync(readmePath)) {
|
||||
found.push(readmePath);
|
||||
}
|
||||
|
||||
if (current === ctx.directory) break;
|
||||
const parent = dirname(current);
|
||||
if (parent === current) break;
|
||||
if (!parent.startsWith(ctx.directory)) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return found.reverse();
|
||||
}
|
||||
|
||||
async function processFilePathForInjection(
|
||||
filePath: string,
|
||||
sessionID: string,
|
||||
output: ToolExecuteOutput,
|
||||
): Promise<void> {
|
||||
const resolved = resolveFilePath(filePath);
|
||||
if (!resolved) return;
|
||||
|
||||
const dir = dirname(resolved);
|
||||
const cache = getSessionCache(sessionID);
|
||||
const readmePaths = findReadmeMdUp(dir);
|
||||
|
||||
for (const readmePath of readmePaths) {
|
||||
const readmeDir = dirname(readmePath);
|
||||
if (cache.has(readmeDir)) continue;
|
||||
|
||||
try {
|
||||
const content = readFileSync(readmePath, "utf-8");
|
||||
const { result, truncated } = await truncator.truncate(sessionID, content);
|
||||
const truncationNotice = truncated
|
||||
? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${readmePath}]`
|
||||
: "";
|
||||
output.output += `\n\n[Project README: ${readmePath}]\n${result}${truncationNotice}`;
|
||||
cache.add(readmeDir);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
saveInjectedPaths(sessionID, cache);
|
||||
}
|
||||
|
||||
const toolExecuteAfter = async (
|
||||
input: ToolExecuteInput,
|
||||
output: ToolExecuteOutput,
|
||||
) => {
|
||||
const toolName = input.tool.toLowerCase();
|
||||
|
||||
if (toolName === "read") {
|
||||
await processFilePathForInjection(output.title, input.sessionID, output);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const toolExecuteBefore = async (
|
||||
input: ToolExecuteInput,
|
||||
output: ToolExecuteBeforeOutput,
|
||||
): Promise<void> => {
|
||||
void input;
|
||||
void output;
|
||||
};
|
||||
|
||||
const eventHandler = async ({ event }: EventInput) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
if (sessionInfo?.id) {
|
||||
sessionCaches.delete(sessionInfo.id);
|
||||
clearInjectedPaths(sessionInfo.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
||||
if (sessionID) {
|
||||
sessionCaches.delete(sessionID);
|
||||
clearInjectedPaths(sessionID);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
"tool.execute.before": toolExecuteBefore,
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
event: eventHandler,
|
||||
};
|
||||
}
|
||||
export { createDirectoryReadmeInjectorHook } from "./hook";
|
||||
|
||||
55
src/hooks/directory-readme-injector/injector.ts
Normal file
55
src/hooks/directory-readme-injector/injector.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
import type { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
||||
import { findReadmeMdUp, resolveFilePath } from "./finder";
|
||||
import { loadInjectedPaths, saveInjectedPaths } from "./storage";
|
||||
|
||||
type DynamicTruncator = ReturnType<typeof createDynamicTruncator>;
|
||||
|
||||
function getSessionCache(
|
||||
sessionCaches: Map<string, Set<string>>,
|
||||
sessionID: string,
|
||||
): Set<string> {
|
||||
if (!sessionCaches.has(sessionID)) {
|
||||
sessionCaches.set(sessionID, loadInjectedPaths(sessionID));
|
||||
}
|
||||
return sessionCaches.get(sessionID)!;
|
||||
}
|
||||
|
||||
export async function processFilePathForReadmeInjection(input: {
|
||||
ctx: PluginInput;
|
||||
truncator: DynamicTruncator;
|
||||
sessionCaches: Map<string, Set<string>>;
|
||||
filePath: string;
|
||||
sessionID: string;
|
||||
output: { title: string; output: string; metadata: unknown };
|
||||
}): Promise<void> {
|
||||
const resolved = resolveFilePath(input.ctx.directory, input.filePath);
|
||||
if (!resolved) return;
|
||||
|
||||
const dir = dirname(resolved);
|
||||
const cache = getSessionCache(input.sessionCaches, input.sessionID);
|
||||
const readmePaths = findReadmeMdUp({ startDir: dir, rootDir: input.ctx.directory });
|
||||
|
||||
for (const readmePath of readmePaths) {
|
||||
const readmeDir = dirname(readmePath);
|
||||
if (cache.has(readmeDir)) continue;
|
||||
|
||||
try {
|
||||
const content = readFileSync(readmePath, "utf-8");
|
||||
const { result, truncated } = await input.truncator.truncate(
|
||||
input.sessionID,
|
||||
content,
|
||||
);
|
||||
const truncationNotice = truncated
|
||||
? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${readmePath}]`
|
||||
: "";
|
||||
input.output.output += `\n\n[Project README: ${readmePath}]\n${result}${truncationNotice}`;
|
||||
cache.add(readmeDir);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
saveInjectedPaths(input.sessionID, cache);
|
||||
}
|
||||
57
src/hooks/edit-error-recovery/hook.ts
Normal file
57
src/hooks/edit-error-recovery/hook.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
/**
|
||||
* Known Edit tool error patterns that indicate the AI made a mistake
|
||||
*/
|
||||
export const EDIT_ERROR_PATTERNS = [
|
||||
"oldString and newString must be different",
|
||||
"oldString not found",
|
||||
"oldString found multiple times",
|
||||
] as const
|
||||
|
||||
/**
|
||||
* System reminder injected when Edit tool fails due to AI mistake
|
||||
* Short, direct, and commanding - forces immediate corrective action
|
||||
*/
|
||||
export const EDIT_ERROR_REMINDER = `
|
||||
[EDIT ERROR - IMMEDIATE ACTION REQUIRED]
|
||||
|
||||
You made an Edit mistake. STOP and do this NOW:
|
||||
|
||||
1. READ the file immediately to see its ACTUAL current state
|
||||
2. VERIFY what the content really looks like (your assumption was wrong)
|
||||
3. APOLOGIZE briefly to the user for the error
|
||||
4. CONTINUE with corrected action based on the real file content
|
||||
|
||||
DO NOT attempt another edit until you've read and verified the file state.
|
||||
`
|
||||
|
||||
/**
|
||||
* Detects Edit tool errors caused by AI mistakes and injects a recovery reminder
|
||||
*
|
||||
* This hook catches common Edit tool failures:
|
||||
* - oldString and newString must be different (trying to "edit" to same content)
|
||||
* - oldString not found (wrong assumption about file content)
|
||||
* - oldString found multiple times (ambiguous match, need more context)
|
||||
*
|
||||
* @see https://github.com/sst/opencode/issues/4718
|
||||
*/
|
||||
export function createEditErrorRecoveryHook(_ctx: PluginInput) {
|
||||
return {
|
||||
"tool.execute.after": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { title: string; output: string; metadata: unknown }
|
||||
) => {
|
||||
if (input.tool.toLowerCase() !== "edit") return
|
||||
|
||||
const outputLower = output.output.toLowerCase()
|
||||
const hasEditError = EDIT_ERROR_PATTERNS.some((pattern) =>
|
||||
outputLower.includes(pattern.toLowerCase())
|
||||
)
|
||||
|
||||
if (hasEditError) {
|
||||
output.output += `\n${EDIT_ERROR_REMINDER}`
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -1,57 +1,5 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
/**
|
||||
* Known Edit tool error patterns that indicate the AI made a mistake
|
||||
*/
|
||||
export const EDIT_ERROR_PATTERNS = [
|
||||
"oldString and newString must be different",
|
||||
"oldString not found",
|
||||
"oldString found multiple times",
|
||||
] as const
|
||||
|
||||
/**
|
||||
* System reminder injected when Edit tool fails due to AI mistake
|
||||
* Short, direct, and commanding - forces immediate corrective action
|
||||
*/
|
||||
export const EDIT_ERROR_REMINDER = `
|
||||
[EDIT ERROR - IMMEDIATE ACTION REQUIRED]
|
||||
|
||||
You made an Edit mistake. STOP and do this NOW:
|
||||
|
||||
1. READ the file immediately to see its ACTUAL current state
|
||||
2. VERIFY what the content really looks like (your assumption was wrong)
|
||||
3. APOLOGIZE briefly to the user for the error
|
||||
4. CONTINUE with corrected action based on the real file content
|
||||
|
||||
DO NOT attempt another edit until you've read and verified the file state.
|
||||
`
|
||||
|
||||
/**
|
||||
* Detects Edit tool errors caused by AI mistakes and injects a recovery reminder
|
||||
*
|
||||
* This hook catches common Edit tool failures:
|
||||
* - oldString and newString must be different (trying to "edit" to same content)
|
||||
* - oldString not found (wrong assumption about file content)
|
||||
* - oldString found multiple times (ambiguous match, need more context)
|
||||
*
|
||||
* @see https://github.com/sst/opencode/issues/4718
|
||||
*/
|
||||
export function createEditErrorRecoveryHook(_ctx: PluginInput) {
|
||||
return {
|
||||
"tool.execute.after": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { title: string; output: string; metadata: unknown }
|
||||
) => {
|
||||
if (input.tool.toLowerCase() !== "edit") return
|
||||
|
||||
const outputLower = output.output.toLowerCase()
|
||||
const hasEditError = EDIT_ERROR_PATTERNS.some((pattern) =>
|
||||
outputLower.includes(pattern.toLowerCase())
|
||||
)
|
||||
|
||||
if (hasEditError) {
|
||||
output.output += `\n${EDIT_ERROR_REMINDER}`
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
export {
|
||||
createEditErrorRecoveryHook,
|
||||
EDIT_ERROR_PATTERNS,
|
||||
EDIT_ERROR_REMINDER,
|
||||
} from "./hook";
|
||||
|
||||
115
src/hooks/keyword-detector/hook.ts
Normal file
115
src/hooks/keyword-detector/hook.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { detectKeywordsWithType, extractPromptText } from "./detector"
|
||||
import { isPlannerAgent } from "./constants"
|
||||
import { log } from "../../shared"
|
||||
import {
|
||||
isSystemDirective,
|
||||
removeSystemReminders,
|
||||
} from "../../shared/system-directive"
|
||||
import {
|
||||
getMainSessionID,
|
||||
getSessionAgent,
|
||||
subagentSessions,
|
||||
} from "../../features/claude-code-session-state"
|
||||
import type { ContextCollector } from "../../features/context-injector"
|
||||
|
||||
export function createKeywordDetectorHook(ctx: PluginInput, _collector?: ContextCollector) {
|
||||
return {
|
||||
"chat.message": async (
|
||||
input: {
|
||||
sessionID: string
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
messageID?: string
|
||||
},
|
||||
output: {
|
||||
message: Record<string, unknown>
|
||||
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
|
||||
}
|
||||
): Promise<void> => {
|
||||
const promptText = extractPromptText(output.parts)
|
||||
|
||||
if (isSystemDirective(promptText)) {
|
||||
log(`[keyword-detector] Skipping system directive message`, { sessionID: input.sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const currentAgent = getSessionAgent(input.sessionID) ?? input.agent
|
||||
|
||||
// Remove system-reminder content to prevent automated system messages from triggering mode keywords
|
||||
const cleanText = removeSystemReminders(promptText)
|
||||
const modelID = input.model?.modelID
|
||||
let detectedKeywords = detectKeywordsWithType(cleanText, currentAgent, modelID)
|
||||
|
||||
if (isPlannerAgent(currentAgent)) {
|
||||
detectedKeywords = detectedKeywords.filter((k) => k.type !== "ultrawork")
|
||||
}
|
||||
|
||||
if (detectedKeywords.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip keyword detection for background task sessions to prevent mode injection
|
||||
// (e.g., [analyze-mode]) which incorrectly triggers Prometheus restrictions
|
||||
const isBackgroundTaskSession = subagentSessions.has(input.sessionID)
|
||||
if (isBackgroundTaskSession) {
|
||||
return
|
||||
}
|
||||
|
||||
const mainSessionID = getMainSessionID()
|
||||
const isNonMainSession = mainSessionID && input.sessionID !== mainSessionID
|
||||
|
||||
if (isNonMainSession) {
|
||||
detectedKeywords = detectedKeywords.filter((k) => k.type === "ultrawork")
|
||||
if (detectedKeywords.length === 0) {
|
||||
log(`[keyword-detector] Skipping non-ultrawork keywords in non-main session`, {
|
||||
sessionID: input.sessionID,
|
||||
mainSessionID,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const hasUltrawork = detectedKeywords.some((k) => k.type === "ultrawork")
|
||||
if (hasUltrawork) {
|
||||
log(`[keyword-detector] Ultrawork mode activated`, { sessionID: input.sessionID })
|
||||
|
||||
if (output.message.variant === undefined) {
|
||||
output.message.variant = "max"
|
||||
}
|
||||
|
||||
ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Ultrawork Mode Activated",
|
||||
message: "Maximum precision engaged. All agents at your disposal.",
|
||||
variant: "success" as const,
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch((err) =>
|
||||
log(`[keyword-detector] Failed to show toast`, {
|
||||
error: err,
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const textPartIndex = output.parts.findIndex((p) => p.type === "text" && p.text !== undefined)
|
||||
if (textPartIndex === -1) {
|
||||
log(`[keyword-detector] No text part found, skipping injection`, { sessionID: input.sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const allMessages = detectedKeywords.map((k) => k.message).join("\n\n")
|
||||
const originalText = output.parts[textPartIndex].text ?? ""
|
||||
|
||||
output.parts[textPartIndex].text = `${allMessages}\n\n---\n\n${originalText}`
|
||||
|
||||
log(`[keyword-detector] Detected ${detectedKeywords.length} keywords`, {
|
||||
sessionID: input.sessionID,
|
||||
types: detectedKeywords.map((k) => k.type),
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -1,109 +1,5 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { detectKeywordsWithType, extractPromptText, removeCodeBlocks } from "./detector"
|
||||
import { isPlannerAgent } from "./constants"
|
||||
import { log } from "../../shared"
|
||||
import { hasSystemReminder, isSystemDirective, removeSystemReminders } from "../../shared/system-directive"
|
||||
import { getMainSessionID, getSessionAgent, subagentSessions } from "../../features/claude-code-session-state"
|
||||
import type { ContextCollector } from "../../features/context-injector"
|
||||
|
||||
export * from "./detector"
|
||||
export * from "./constants"
|
||||
export * from "./types"
|
||||
|
||||
export function createKeywordDetectorHook(ctx: PluginInput, collector?: ContextCollector) {
|
||||
return {
|
||||
"chat.message": async (
|
||||
input: {
|
||||
sessionID: string
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
messageID?: string
|
||||
},
|
||||
output: {
|
||||
message: Record<string, unknown>
|
||||
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
|
||||
}
|
||||
): Promise<void> => {
|
||||
const promptText = extractPromptText(output.parts)
|
||||
|
||||
if (isSystemDirective(promptText)) {
|
||||
log(`[keyword-detector] Skipping system directive message`, { sessionID: input.sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const currentAgent = getSessionAgent(input.sessionID) ?? input.agent
|
||||
|
||||
// Remove system-reminder content to prevent automated system messages from triggering mode keywords
|
||||
const cleanText = removeSystemReminders(promptText)
|
||||
const modelID = input.model?.modelID
|
||||
let detectedKeywords = detectKeywordsWithType(cleanText, currentAgent, modelID)
|
||||
|
||||
if (isPlannerAgent(currentAgent)) {
|
||||
detectedKeywords = detectedKeywords.filter((k) => k.type !== "ultrawork")
|
||||
}
|
||||
|
||||
if (detectedKeywords.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Skip keyword detection for background task sessions to prevent mode injection
|
||||
// (e.g., [analyze-mode]) which incorrectly triggers Prometheus restrictions
|
||||
const isBackgroundTaskSession = subagentSessions.has(input.sessionID)
|
||||
if (isBackgroundTaskSession) {
|
||||
return
|
||||
}
|
||||
|
||||
const mainSessionID = getMainSessionID()
|
||||
const isNonMainSession = mainSessionID && input.sessionID !== mainSessionID
|
||||
|
||||
if (isNonMainSession) {
|
||||
detectedKeywords = detectedKeywords.filter((k) => k.type === "ultrawork")
|
||||
if (detectedKeywords.length === 0) {
|
||||
log(`[keyword-detector] Skipping non-ultrawork keywords in non-main session`, {
|
||||
sessionID: input.sessionID,
|
||||
mainSessionID,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const hasUltrawork = detectedKeywords.some((k) => k.type === "ultrawork")
|
||||
if (hasUltrawork) {
|
||||
log(`[keyword-detector] Ultrawork mode activated`, { sessionID: input.sessionID })
|
||||
|
||||
if (output.message.variant === undefined) {
|
||||
output.message.variant = "max"
|
||||
}
|
||||
|
||||
ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Ultrawork Mode Activated",
|
||||
message: "Maximum precision engaged. All agents at your disposal.",
|
||||
variant: "success" as const,
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch((err) =>
|
||||
log(`[keyword-detector] Failed to show toast`, { error: err, sessionID: input.sessionID })
|
||||
)
|
||||
}
|
||||
|
||||
const textPartIndex = output.parts.findIndex((p) => p.type === "text" && p.text !== undefined)
|
||||
if (textPartIndex === -1) {
|
||||
log(`[keyword-detector] No text part found, skipping injection`, { sessionID: input.sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const allMessages = detectedKeywords.map((k) => k.message).join("\n\n")
|
||||
const originalText = output.parts[textPartIndex].text ?? ""
|
||||
|
||||
output.parts[textPartIndex].text = `${allMessages}\n\n---\n\n${originalText}`
|
||||
|
||||
log(`[keyword-detector] Detected ${detectedKeywords.length} keywords`, {
|
||||
sessionID: input.sessionID,
|
||||
types: detectedKeywords.map((k) => k.type),
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
export { createKeywordDetectorHook } from "./hook"
|
||||
|
||||
@ -7,13 +7,13 @@
|
||||
* 3. Default (Claude, etc.) → default.ts (optimized for Claude series)
|
||||
*/
|
||||
|
||||
export { isPlannerAgent, isGptModel, getUltraworkSource } from "./utils"
|
||||
export type { UltraworkSource } from "./utils"
|
||||
export { isPlannerAgent, isGptModel, getUltraworkSource } from "./source-detector"
|
||||
export type { UltraworkSource } from "./source-detector"
|
||||
export { ULTRAWORK_PLANNER_SECTION, getPlannerUltraworkMessage } from "./planner"
|
||||
export { ULTRAWORK_GPT_MESSAGE, getGptUltraworkMessage } from "./gpt5.2"
|
||||
export { ULTRAWORK_DEFAULT_MESSAGE, getDefaultUltraworkMessage } from "./default"
|
||||
|
||||
import { getUltraworkSource } from "./utils"
|
||||
import { getUltraworkSource } from "./source-detector"
|
||||
import { getPlannerUltraworkMessage } from "./planner"
|
||||
import { getGptUltraworkMessage } from "./gpt5.2"
|
||||
import { getDefaultUltraworkMessage } from "./default"
|
||||
|
||||
@ -36,7 +36,10 @@ export type UltraworkSource = "planner" | "gpt" | "default"
|
||||
/**
|
||||
* Determines which ultrawork message source to use.
|
||||
*/
|
||||
export function getUltraworkSource(agentName?: string, modelID?: string): UltraworkSource {
|
||||
export function getUltraworkSource(
|
||||
agentName?: string,
|
||||
modelID?: string
|
||||
): UltraworkSource {
|
||||
// Priority 1: Planner agents
|
||||
if (isPlannerAgent(agentName)) {
|
||||
return "planner"
|
||||
52
src/hooks/prometheus-md-only/agent-resolution.ts
Normal file
52
src/hooks/prometheus-md-only/agent-resolution.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { existsSync, readdirSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { readBoulderState } from "../../features/boulder-state"
|
||||
|
||||
function getMessageDir(sessionID: string): string | null {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||
|
||||
const directPath = join(MESSAGE_STORAGE, sessionID)
|
||||
if (existsSync(directPath)) return directPath
|
||||
|
||||
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
||||
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
||||
if (existsSync(sessionPath)) return sessionPath
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function getAgentFromMessageFiles(sessionID: string): string | undefined {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
if (!messageDir) return undefined
|
||||
return findFirstMessageWithAgent(messageDir) ?? findNearestMessageWithFields(messageDir)?.agent
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective agent for the session.
|
||||
* Priority order:
|
||||
* 1. In-memory session agent (most recent, set by /start-work)
|
||||
* 2. Boulder state agent (persisted across restarts, fixes #927)
|
||||
* 3. Message files (fallback for sessions without boulder state)
|
||||
*
|
||||
* This fixes issue #927 where after interruption:
|
||||
* - In-memory map is cleared (process restart)
|
||||
* - Message files return "prometheus" (oldest message from /plan)
|
||||
* - But boulder.json has agent: "atlas" (set by /start-work)
|
||||
*/
|
||||
export function getAgentFromSession(sessionID: string, directory: string): string | undefined {
|
||||
// Check in-memory first (current session)
|
||||
const memoryAgent = getSessionAgent(sessionID)
|
||||
if (memoryAgent) return memoryAgent
|
||||
|
||||
// Check boulder state (persisted across restarts) - fixes #927
|
||||
const boulderState = readBoulderState(directory)
|
||||
if (boulderState?.session_ids.includes(sessionID) && boulderState.agent) {
|
||||
return boulderState.agent
|
||||
}
|
||||
|
||||
// Fallback to message files
|
||||
return getAgentFromMessageFiles(sessionID)
|
||||
}
|
||||
96
src/hooks/prometheus-md-only/hook.ts
Normal file
96
src/hooks/prometheus-md-only/hook.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { HOOK_NAME, PROMETHEUS_AGENT, BLOCKED_TOOLS, PLANNING_CONSULT_WARNING, PROMETHEUS_WORKFLOW_REMINDER } from "./constants"
|
||||
import { log } from "../../shared/logger"
|
||||
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
|
||||
import { getAgentDisplayName } from "../../shared/agent-display-names"
|
||||
import { getAgentFromSession } from "./agent-resolution"
|
||||
import { isAllowedFile } from "./path-policy"
|
||||
|
||||
const TASK_TOOLS = ["task", "call_omo_agent"]
|
||||
|
||||
export function createPrometheusMdOnlyHook(ctx: PluginInput) {
|
||||
return {
|
||||
"tool.execute.before": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { args: Record<string, unknown>; message?: string }
|
||||
): Promise<void> => {
|
||||
const agentName = getAgentFromSession(input.sessionID, ctx.directory)
|
||||
|
||||
if (agentName !== PROMETHEUS_AGENT) {
|
||||
return
|
||||
}
|
||||
|
||||
const toolName = input.tool
|
||||
|
||||
// Inject read-only warning for task tools called by Prometheus
|
||||
if (TASK_TOOLS.includes(toolName)) {
|
||||
const prompt = output.args.prompt as string | undefined
|
||||
if (prompt && !prompt.includes(SYSTEM_DIRECTIVE_PREFIX)) {
|
||||
output.args.prompt = PLANNING_CONSULT_WARNING + prompt
|
||||
log(`[${HOOK_NAME}] Injected read-only planning warning to ${toolName}`, {
|
||||
sessionID: input.sessionID,
|
||||
tool: toolName,
|
||||
agent: agentName,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!BLOCKED_TOOLS.includes(toolName)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Block bash commands completely - Prometheus is read-only
|
||||
if (toolName === "bash") {
|
||||
log(`[${HOOK_NAME}] Blocked: Prometheus cannot execute bash commands`, {
|
||||
sessionID: input.sessionID,
|
||||
tool: toolName,
|
||||
agent: agentName,
|
||||
})
|
||||
throw new Error(
|
||||
`[${HOOK_NAME}] ${getAgentDisplayName("prometheus")} cannot execute bash commands. ` +
|
||||
`${getAgentDisplayName("prometheus")} is a READ-ONLY planner. Use /start-work to execute the plan. ` +
|
||||
`APOLOGIZE TO THE USER, REMIND OF YOUR PLAN WRITING PROCESSES, TELL USER WHAT YOU WILL GOING TO DO AS THE PROCESS, WRITE THE PLAN`
|
||||
)
|
||||
}
|
||||
|
||||
const filePath = (output.args.filePath ?? output.args.path ?? output.args.file) as string | undefined
|
||||
if (!filePath) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isAllowedFile(filePath, ctx.directory)) {
|
||||
log(`[${HOOK_NAME}] Blocked: Prometheus can only write to .sisyphus/*.md`, {
|
||||
sessionID: input.sessionID,
|
||||
tool: toolName,
|
||||
filePath,
|
||||
agent: agentName,
|
||||
})
|
||||
throw new Error(
|
||||
`[${HOOK_NAME}] ${getAgentDisplayName("prometheus")} can only write/edit .md files inside .sisyphus/ directory. ` +
|
||||
`Attempted to modify: ${filePath}. ` +
|
||||
`${getAgentDisplayName("prometheus")} is a READ-ONLY planner. Use /start-work to execute the plan. ` +
|
||||
`APOLOGIZE TO THE USER, REMIND OF YOUR PLAN WRITING PROCESSES, TELL USER WHAT YOU WILL GOING TO DO AS THE PROCESS, WRITE THE PLAN`
|
||||
)
|
||||
}
|
||||
|
||||
const normalizedPath = filePath.toLowerCase().replace(/\\/g, "/")
|
||||
if (normalizedPath.includes(".sisyphus/plans/") || normalizedPath.includes(".sisyphus\\plans\\")) {
|
||||
log(`[${HOOK_NAME}] Injecting workflow reminder for plan write`, {
|
||||
sessionID: input.sessionID,
|
||||
tool: toolName,
|
||||
filePath,
|
||||
agent: agentName,
|
||||
})
|
||||
output.message = (output.message || "") + PROMETHEUS_WORKFLOW_REMINDER
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Allowed: .sisyphus/*.md write permitted`, {
|
||||
sessionID: input.sessionID,
|
||||
tool: toolName,
|
||||
filePath,
|
||||
agent: agentName,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -1,186 +1,2 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { existsSync, readdirSync } from "node:fs"
|
||||
import { join, resolve, relative, isAbsolute } from "node:path"
|
||||
import { HOOK_NAME, PROMETHEUS_AGENT, ALLOWED_EXTENSIONS, ALLOWED_PATH_PREFIX, BLOCKED_TOOLS, PLANNING_CONSULT_WARNING, PROMETHEUS_WORKFLOW_REMINDER } from "./constants"
|
||||
import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
import { getSessionAgent } from "../../features/claude-code-session-state"
|
||||
import { readBoulderState } from "../../features/boulder-state"
|
||||
import { log } from "../../shared/logger"
|
||||
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
|
||||
import { getAgentDisplayName } from "../../shared/agent-display-names"
|
||||
|
||||
export * from "./constants"
|
||||
|
||||
/**
|
||||
* Cross-platform path validator for Prometheus file writes.
|
||||
* Uses path.resolve/relative instead of string matching to handle:
|
||||
* - Windows backslashes (e.g., .sisyphus\\plans\\x.md)
|
||||
* - Mixed separators (e.g., .sisyphus\\plans/x.md)
|
||||
* - Case-insensitive directory/extension matching
|
||||
* - Workspace confinement (blocks paths outside root or via traversal)
|
||||
* - Nested project paths (e.g., parent/.sisyphus/... when ctx.directory is parent)
|
||||
*/
|
||||
function isAllowedFile(filePath: string, workspaceRoot: string): boolean {
|
||||
// 1. Resolve to absolute path
|
||||
const resolved = resolve(workspaceRoot, filePath)
|
||||
|
||||
// 2. Get relative path from workspace root
|
||||
const rel = relative(workspaceRoot, resolved)
|
||||
|
||||
// 3. Reject if escapes root (starts with ".." or is absolute)
|
||||
if (rel.startsWith("..") || isAbsolute(rel)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 4. Check if .sisyphus/ or .sisyphus\ exists anywhere in the path (case-insensitive)
|
||||
// This handles both direct paths (.sisyphus/x.md) and nested paths (project/.sisyphus/x.md)
|
||||
if (!/\.sisyphus[/\\]/i.test(rel)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 5. Check extension matches one of ALLOWED_EXTENSIONS (case-insensitive)
|
||||
const hasAllowedExtension = ALLOWED_EXTENSIONS.some(
|
||||
ext => resolved.toLowerCase().endsWith(ext.toLowerCase())
|
||||
)
|
||||
if (!hasAllowedExtension) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function getMessageDir(sessionID: string): string | null {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||
|
||||
const directPath = join(MESSAGE_STORAGE, sessionID)
|
||||
if (existsSync(directPath)) return directPath
|
||||
|
||||
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
||||
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
||||
if (existsSync(sessionPath)) return sessionPath
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const TASK_TOOLS = ["task", "call_omo_agent"]
|
||||
|
||||
function getAgentFromMessageFiles(sessionID: string): string | undefined {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
if (!messageDir) return undefined
|
||||
return findFirstMessageWithAgent(messageDir) ?? findNearestMessageWithFields(messageDir)?.agent
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the effective agent for the session.
|
||||
* Priority order:
|
||||
* 1. In-memory session agent (most recent, set by /start-work)
|
||||
* 2. Boulder state agent (persisted across restarts, fixes #927)
|
||||
* 3. Message files (fallback for sessions without boulder state)
|
||||
*
|
||||
* This fixes issue #927 where after interruption:
|
||||
* - In-memory map is cleared (process restart)
|
||||
* - Message files return "prometheus" (oldest message from /plan)
|
||||
* - But boulder.json has agent: "atlas" (set by /start-work)
|
||||
*/
|
||||
function getAgentFromSession(sessionID: string, directory: string): string | undefined {
|
||||
// Check in-memory first (current session)
|
||||
const memoryAgent = getSessionAgent(sessionID)
|
||||
if (memoryAgent) return memoryAgent
|
||||
|
||||
// Check boulder state (persisted across restarts) - fixes #927
|
||||
const boulderState = readBoulderState(directory)
|
||||
if (boulderState?.session_ids.includes(sessionID) && boulderState.agent) {
|
||||
return boulderState.agent
|
||||
}
|
||||
|
||||
// Fallback to message files
|
||||
return getAgentFromMessageFiles(sessionID)
|
||||
}
|
||||
|
||||
export function createPrometheusMdOnlyHook(ctx: PluginInput) {
|
||||
return {
|
||||
"tool.execute.before": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { args: Record<string, unknown>; message?: string }
|
||||
): Promise<void> => {
|
||||
const agentName = getAgentFromSession(input.sessionID, ctx.directory)
|
||||
|
||||
if (agentName !== PROMETHEUS_AGENT) {
|
||||
return
|
||||
}
|
||||
|
||||
const toolName = input.tool
|
||||
|
||||
// Inject read-only warning for task tools called by Prometheus
|
||||
if (TASK_TOOLS.includes(toolName)) {
|
||||
const prompt = output.args.prompt as string | undefined
|
||||
if (prompt && !prompt.includes(SYSTEM_DIRECTIVE_PREFIX)) {
|
||||
output.args.prompt = PLANNING_CONSULT_WARNING + prompt
|
||||
log(`[${HOOK_NAME}] Injected read-only planning warning to ${toolName}`, {
|
||||
sessionID: input.sessionID,
|
||||
tool: toolName,
|
||||
agent: agentName,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!BLOCKED_TOOLS.includes(toolName)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Block bash commands completely - Prometheus is read-only
|
||||
if (toolName === "bash") {
|
||||
log(`[${HOOK_NAME}] Blocked: Prometheus cannot execute bash commands`, {
|
||||
sessionID: input.sessionID,
|
||||
tool: toolName,
|
||||
agent: agentName,
|
||||
})
|
||||
throw new Error(
|
||||
`[${HOOK_NAME}] ${getAgentDisplayName("prometheus")} cannot execute bash commands. ` +
|
||||
`${getAgentDisplayName("prometheus")} is a READ-ONLY planner. Use /start-work to execute the plan. ` +
|
||||
`APOLOGIZE TO THE USER, REMIND OF YOUR PLAN WRITING PROCESSES, TELL USER WHAT YOU WILL GOING TO DO AS THE PROCESS, WRITE THE PLAN`
|
||||
)
|
||||
}
|
||||
|
||||
const filePath = (output.args.filePath ?? output.args.path ?? output.args.file) as string | undefined
|
||||
if (!filePath) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isAllowedFile(filePath, ctx.directory)) {
|
||||
log(`[${HOOK_NAME}] Blocked: Prometheus can only write to .sisyphus/*.md`, {
|
||||
sessionID: input.sessionID,
|
||||
tool: toolName,
|
||||
filePath,
|
||||
agent: agentName,
|
||||
})
|
||||
throw new Error(
|
||||
`[${HOOK_NAME}] ${getAgentDisplayName("prometheus")} can only write/edit .md files inside .sisyphus/ directory. ` +
|
||||
`Attempted to modify: ${filePath}. ` +
|
||||
`${getAgentDisplayName("prometheus")} is a READ-ONLY planner. Use /start-work to execute the plan. ` +
|
||||
`APOLOGIZE TO THE USER, REMIND OF YOUR PLAN WRITING PROCESSES, TELL USER WHAT YOU WILL GOING TO DO AS THE PROCESS, WRITE THE PLAN`
|
||||
)
|
||||
}
|
||||
|
||||
const normalizedPath = filePath.toLowerCase().replace(/\\/g, "/")
|
||||
if (normalizedPath.includes(".sisyphus/plans/") || normalizedPath.includes(".sisyphus\\plans\\")) {
|
||||
log(`[${HOOK_NAME}] Injecting workflow reminder for plan write`, {
|
||||
sessionID: input.sessionID,
|
||||
tool: toolName,
|
||||
filePath,
|
||||
agent: agentName,
|
||||
})
|
||||
output.message = (output.message || "") + PROMETHEUS_WORKFLOW_REMINDER
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Allowed: .sisyphus/*.md write permitted`, {
|
||||
sessionID: input.sessionID,
|
||||
tool: toolName,
|
||||
filePath,
|
||||
agent: agentName,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
export { createPrometheusMdOnlyHook } from "./hook"
|
||||
|
||||
41
src/hooks/prometheus-md-only/path-policy.ts
Normal file
41
src/hooks/prometheus-md-only/path-policy.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { relative, resolve, isAbsolute } from "node:path"
|
||||
|
||||
import { ALLOWED_EXTENSIONS } from "./constants"
|
||||
|
||||
/**
|
||||
* Cross-platform path validator for Prometheus file writes.
|
||||
* Uses path.resolve/relative instead of string matching to handle:
|
||||
* - Windows backslashes (e.g., .sisyphus\\plans\\x.md)
|
||||
* - Mixed separators (e.g., .sisyphus\\plans/x.md)
|
||||
* - Case-insensitive directory/extension matching
|
||||
* - Workspace confinement (blocks paths outside root or via traversal)
|
||||
* - Nested project paths (e.g., parent/.sisyphus/... when ctx.directory is parent)
|
||||
*/
|
||||
export function isAllowedFile(filePath: string, workspaceRoot: string): boolean {
|
||||
// 1. Resolve to absolute path
|
||||
const resolved = resolve(workspaceRoot, filePath)
|
||||
|
||||
// 2. Get relative path from workspace root
|
||||
const rel = relative(workspaceRoot, resolved)
|
||||
|
||||
// 3. Reject if escapes root (starts with ".." or is absolute)
|
||||
if (rel.startsWith("..") || isAbsolute(rel)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 4. Check if .sisyphus/ or .sisyphus\ exists anywhere in the path (case-insensitive)
|
||||
// This handles both direct paths (.sisyphus/x.md) and nested paths (project/.sisyphus/x.md)
|
||||
if (!/\.sisyphus[/\\]/i.test(rel)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 5. Check extension matches one of ALLOWED_EXTENSIONS (case-insensitive)
|
||||
const hasAllowedExtension = ALLOWED_EXTENSIONS.some(
|
||||
ext => resolved.toLowerCase().endsWith(ext.toLowerCase())
|
||||
)
|
||||
if (!hasAllowedExtension) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
62
src/hooks/question-label-truncator/hook.ts
Normal file
62
src/hooks/question-label-truncator/hook.ts
Normal file
@ -0,0 +1,62 @@
|
||||
const MAX_LABEL_LENGTH = 30;
|
||||
|
||||
interface QuestionOption {
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface Question {
|
||||
question: string;
|
||||
header?: string;
|
||||
options: QuestionOption[];
|
||||
multiSelect?: boolean;
|
||||
}
|
||||
|
||||
interface AskUserQuestionArgs {
|
||||
questions: Question[];
|
||||
}
|
||||
|
||||
function truncateLabel(label: string, maxLength: number = MAX_LABEL_LENGTH): string {
|
||||
if (label.length <= maxLength) {
|
||||
return label;
|
||||
}
|
||||
return label.substring(0, maxLength - 3) + "...";
|
||||
}
|
||||
|
||||
function truncateQuestionLabels(args: AskUserQuestionArgs): AskUserQuestionArgs {
|
||||
if (!args.questions || !Array.isArray(args.questions)) {
|
||||
return args;
|
||||
}
|
||||
|
||||
return {
|
||||
...args,
|
||||
questions: args.questions.map((question) => ({
|
||||
...question,
|
||||
options:
|
||||
question.options?.map((option) => ({
|
||||
...option,
|
||||
label: truncateLabel(option.label),
|
||||
})) ?? [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function createQuestionLabelTruncatorHook() {
|
||||
return {
|
||||
"tool.execute.before": async (
|
||||
input: { tool: string },
|
||||
output: { args: Record<string, unknown> }
|
||||
): Promise<void> => {
|
||||
const toolName = input.tool?.toLowerCase();
|
||||
|
||||
if (toolName === "askuserquestion" || toolName === "ask_user_question") {
|
||||
const args = output.args as unknown as AskUserQuestionArgs | undefined;
|
||||
|
||||
if (args?.questions) {
|
||||
const truncatedArgs = truncateQuestionLabels(args);
|
||||
Object.assign(output.args, truncatedArgs);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -1,61 +1 @@
|
||||
const MAX_LABEL_LENGTH = 30;
|
||||
|
||||
interface QuestionOption {
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface Question {
|
||||
question: string;
|
||||
header?: string;
|
||||
options: QuestionOption[];
|
||||
multiSelect?: boolean;
|
||||
}
|
||||
|
||||
interface AskUserQuestionArgs {
|
||||
questions: Question[];
|
||||
}
|
||||
|
||||
function truncateLabel(label: string, maxLength: number = MAX_LABEL_LENGTH): string {
|
||||
if (label.length <= maxLength) {
|
||||
return label;
|
||||
}
|
||||
return label.substring(0, maxLength - 3) + "...";
|
||||
}
|
||||
|
||||
function truncateQuestionLabels(args: AskUserQuestionArgs): AskUserQuestionArgs {
|
||||
if (!args.questions || !Array.isArray(args.questions)) {
|
||||
return args;
|
||||
}
|
||||
|
||||
return {
|
||||
...args,
|
||||
questions: args.questions.map((question) => ({
|
||||
...question,
|
||||
options: question.options?.map((option) => ({
|
||||
...option,
|
||||
label: truncateLabel(option.label),
|
||||
})) ?? [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function createQuestionLabelTruncatorHook() {
|
||||
return {
|
||||
"tool.execute.before": async (
|
||||
input: { tool: string },
|
||||
output: { args: Record<string, unknown> }
|
||||
): Promise<void> => {
|
||||
const toolName = input.tool?.toLowerCase();
|
||||
|
||||
if (toolName === "askuserquestion" || toolName === "ask_user_question") {
|
||||
const args = output.args as unknown as AskUserQuestionArgs | undefined;
|
||||
|
||||
if (args?.questions) {
|
||||
const truncatedArgs = truncateQuestionLabels(args);
|
||||
Object.assign(output.args, truncatedArgs);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
export { createQuestionLabelTruncatorHook } from "./hook";
|
||||
|
||||
27
src/hooks/rules-injector/cache.ts
Normal file
27
src/hooks/rules-injector/cache.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { clearInjectedRules, loadInjectedRules } from "./storage";
|
||||
|
||||
export type SessionInjectedRulesCache = {
|
||||
contentHashes: Set<string>;
|
||||
realPaths: Set<string>;
|
||||
};
|
||||
|
||||
export function createSessionCacheStore(): {
|
||||
getSessionCache: (sessionID: string) => SessionInjectedRulesCache;
|
||||
clearSessionCache: (sessionID: string) => void;
|
||||
} {
|
||||
const sessionCaches = new Map<string, SessionInjectedRulesCache>();
|
||||
|
||||
function getSessionCache(sessionID: string): SessionInjectedRulesCache {
|
||||
if (!sessionCaches.has(sessionID)) {
|
||||
sessionCaches.set(sessionID, loadInjectedRules(sessionID));
|
||||
}
|
||||
return sessionCaches.get(sessionID)!;
|
||||
}
|
||||
|
||||
function clearSessionCache(sessionID: string): void {
|
||||
sessionCaches.delete(sessionID);
|
||||
clearInjectedRules(sessionID);
|
||||
}
|
||||
|
||||
return { getSessionCache, clearSessionCache };
|
||||
}
|
||||
87
src/hooks/rules-injector/hook.ts
Normal file
87
src/hooks/rules-injector/hook.ts
Normal file
@ -0,0 +1,87 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
||||
import { getRuleInjectionFilePath } from "./output-path";
|
||||
import { createSessionCacheStore } from "./cache";
|
||||
import { createRuleInjectionProcessor } from "./injector";
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string;
|
||||
sessionID: string;
|
||||
callID: string;
|
||||
}
|
||||
|
||||
interface ToolExecuteOutput {
|
||||
title: string;
|
||||
output: string;
|
||||
metadata: unknown;
|
||||
}
|
||||
|
||||
interface ToolExecuteBeforeOutput {
|
||||
args: unknown;
|
||||
}
|
||||
|
||||
interface EventInput {
|
||||
event: {
|
||||
type: string;
|
||||
properties?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
const TRACKED_TOOLS = ["read", "write", "edit", "multiedit"];
|
||||
|
||||
export function createRulesInjectorHook(ctx: PluginInput) {
|
||||
const truncator = createDynamicTruncator(ctx);
|
||||
const { getSessionCache, clearSessionCache } = createSessionCacheStore();
|
||||
const { processFilePathForInjection } = createRuleInjectionProcessor({
|
||||
workspaceDirectory: ctx.directory,
|
||||
truncator,
|
||||
getSessionCache,
|
||||
});
|
||||
|
||||
const toolExecuteAfter = async (
|
||||
input: ToolExecuteInput,
|
||||
output: ToolExecuteOutput
|
||||
) => {
|
||||
const toolName = input.tool.toLowerCase();
|
||||
|
||||
if (TRACKED_TOOLS.includes(toolName)) {
|
||||
const filePath = getRuleInjectionFilePath(output);
|
||||
if (!filePath) return;
|
||||
await processFilePathForInjection(filePath, input.sessionID, output);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const toolExecuteBefore = async (
|
||||
input: ToolExecuteInput,
|
||||
output: ToolExecuteBeforeOutput
|
||||
): Promise<void> => {
|
||||
void input;
|
||||
void output;
|
||||
};
|
||||
|
||||
const eventHandler = async ({ event }: EventInput) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
if (sessionInfo?.id) {
|
||||
clearSessionCache(sessionInfo.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
||||
if (sessionID) {
|
||||
clearSessionCache(sessionID);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
"tool.execute.before": toolExecuteBefore,
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
event: eventHandler,
|
||||
};
|
||||
}
|
||||
@ -1,190 +1 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { relative, resolve } from "node:path";
|
||||
import { findProjectRoot, findRuleFiles } from "./finder";
|
||||
import {
|
||||
createContentHash,
|
||||
isDuplicateByContentHash,
|
||||
isDuplicateByRealPath,
|
||||
shouldApplyRule,
|
||||
} from "./matcher";
|
||||
import { parseRuleFrontmatter } from "./parser";
|
||||
import {
|
||||
clearInjectedRules,
|
||||
loadInjectedRules,
|
||||
saveInjectedRules,
|
||||
} from "./storage";
|
||||
import { createDynamicTruncator } from "../../shared/dynamic-truncator";
|
||||
import { getRuleInjectionFilePath } from "./output-path";
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string;
|
||||
sessionID: string;
|
||||
callID: string;
|
||||
}
|
||||
|
||||
interface ToolExecuteOutput {
|
||||
title: string;
|
||||
output: string;
|
||||
metadata: unknown;
|
||||
}
|
||||
|
||||
interface ToolExecuteBeforeOutput {
|
||||
args: unknown;
|
||||
}
|
||||
|
||||
interface EventInput {
|
||||
event: {
|
||||
type: string;
|
||||
properties?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
interface RuleToInject {
|
||||
relativePath: string;
|
||||
matchReason: string;
|
||||
content: string;
|
||||
distance: number;
|
||||
}
|
||||
|
||||
const TRACKED_TOOLS = ["read", "write", "edit", "multiedit"];
|
||||
|
||||
export function createRulesInjectorHook(ctx: PluginInput) {
|
||||
const sessionCaches = new Map<
|
||||
string,
|
||||
{ contentHashes: Set<string>; realPaths: Set<string> }
|
||||
>();
|
||||
const truncator = createDynamicTruncator(ctx);
|
||||
|
||||
function getSessionCache(sessionID: string): {
|
||||
contentHashes: Set<string>;
|
||||
realPaths: Set<string>;
|
||||
} {
|
||||
if (!sessionCaches.has(sessionID)) {
|
||||
sessionCaches.set(sessionID, loadInjectedRules(sessionID));
|
||||
}
|
||||
return sessionCaches.get(sessionID)!;
|
||||
}
|
||||
|
||||
function resolveFilePath(path: string): string | null {
|
||||
if (!path) return null;
|
||||
if (path.startsWith("/")) return path;
|
||||
return resolve(ctx.directory, path);
|
||||
}
|
||||
|
||||
|
||||
async function processFilePathForInjection(
|
||||
filePath: string,
|
||||
sessionID: string,
|
||||
output: ToolExecuteOutput
|
||||
): Promise<void> {
|
||||
const resolved = resolveFilePath(filePath);
|
||||
if (!resolved) return;
|
||||
|
||||
const projectRoot = findProjectRoot(resolved);
|
||||
const cache = getSessionCache(sessionID);
|
||||
const home = homedir();
|
||||
|
||||
const ruleFileCandidates = findRuleFiles(projectRoot, home, resolved);
|
||||
const toInject: RuleToInject[] = [];
|
||||
|
||||
for (const candidate of ruleFileCandidates) {
|
||||
if (isDuplicateByRealPath(candidate.realPath, cache.realPaths)) continue;
|
||||
|
||||
try {
|
||||
const rawContent = readFileSync(candidate.path, "utf-8");
|
||||
const { metadata, body } = parseRuleFrontmatter(rawContent);
|
||||
|
||||
let matchReason: string;
|
||||
if (candidate.isSingleFile) {
|
||||
matchReason = "copilot-instructions (always apply)";
|
||||
} else {
|
||||
const matchResult = shouldApplyRule(metadata, resolved, projectRoot);
|
||||
if (!matchResult.applies) continue;
|
||||
matchReason = matchResult.reason ?? "matched";
|
||||
}
|
||||
|
||||
const contentHash = createContentHash(body);
|
||||
if (isDuplicateByContentHash(contentHash, cache.contentHashes)) continue;
|
||||
|
||||
const relativePath = projectRoot
|
||||
? relative(projectRoot, candidate.path)
|
||||
: candidate.path;
|
||||
|
||||
toInject.push({
|
||||
relativePath,
|
||||
matchReason,
|
||||
content: body,
|
||||
distance: candidate.distance,
|
||||
});
|
||||
|
||||
cache.realPaths.add(candidate.realPath);
|
||||
cache.contentHashes.add(contentHash);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (toInject.length === 0) return;
|
||||
|
||||
toInject.sort((a, b) => a.distance - b.distance);
|
||||
|
||||
for (const rule of toInject) {
|
||||
const { result, truncated } = await truncator.truncate(sessionID, rule.content);
|
||||
const truncationNotice = truncated
|
||||
? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${rule.relativePath}]`
|
||||
: "";
|
||||
output.output += `\n\n[Rule: ${rule.relativePath}]\n[Match: ${rule.matchReason}]\n${result}${truncationNotice}`;
|
||||
}
|
||||
|
||||
saveInjectedRules(sessionID, cache);
|
||||
}
|
||||
|
||||
const toolExecuteAfter = async (
|
||||
input: ToolExecuteInput,
|
||||
output: ToolExecuteOutput
|
||||
) => {
|
||||
const toolName = input.tool.toLowerCase();
|
||||
|
||||
if (TRACKED_TOOLS.includes(toolName)) {
|
||||
const filePath = getRuleInjectionFilePath(output);
|
||||
if (!filePath) return;
|
||||
await processFilePathForInjection(filePath, input.sessionID, output);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const toolExecuteBefore = async (
|
||||
input: ToolExecuteInput,
|
||||
output: ToolExecuteBeforeOutput
|
||||
): Promise<void> => {
|
||||
void input;
|
||||
void output;
|
||||
};
|
||||
|
||||
const eventHandler = async ({ event }: EventInput) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
if (sessionInfo?.id) {
|
||||
sessionCaches.delete(sessionInfo.id);
|
||||
clearInjectedRules(sessionInfo.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
||||
if (sessionID) {
|
||||
sessionCaches.delete(sessionID);
|
||||
clearInjectedRules(sessionID);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
"tool.execute.before": toolExecuteBefore,
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
event: eventHandler,
|
||||
};
|
||||
}
|
||||
export { createRulesInjectorHook } from "./hook";
|
||||
|
||||
126
src/hooks/rules-injector/injector.ts
Normal file
126
src/hooks/rules-injector/injector.ts
Normal file
@ -0,0 +1,126 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { relative, resolve } from "node:path";
|
||||
import { findProjectRoot, findRuleFiles } from "./finder";
|
||||
import {
|
||||
createContentHash,
|
||||
isDuplicateByContentHash,
|
||||
isDuplicateByRealPath,
|
||||
shouldApplyRule,
|
||||
} from "./matcher";
|
||||
import { parseRuleFrontmatter } from "./parser";
|
||||
import { saveInjectedRules } from "./storage";
|
||||
import type { SessionInjectedRulesCache } from "./cache";
|
||||
|
||||
type ToolExecuteOutput = {
|
||||
title: string;
|
||||
output: string;
|
||||
metadata: unknown;
|
||||
};
|
||||
|
||||
type RuleToInject = {
|
||||
relativePath: string;
|
||||
matchReason: string;
|
||||
content: string;
|
||||
distance: number;
|
||||
};
|
||||
|
||||
type DynamicTruncator = {
|
||||
truncate: (
|
||||
sessionID: string,
|
||||
content: string
|
||||
) => Promise<{ result: string; truncated: boolean }>;
|
||||
};
|
||||
|
||||
function resolveFilePath(
|
||||
workspaceDirectory: string,
|
||||
path: string
|
||||
): string | null {
|
||||
if (!path) return null;
|
||||
if (path.startsWith("/")) return path;
|
||||
return resolve(workspaceDirectory, path);
|
||||
}
|
||||
|
||||
export function createRuleInjectionProcessor(deps: {
|
||||
workspaceDirectory: string;
|
||||
truncator: DynamicTruncator;
|
||||
getSessionCache: (sessionID: string) => SessionInjectedRulesCache;
|
||||
}): {
|
||||
processFilePathForInjection: (
|
||||
filePath: string,
|
||||
sessionID: string,
|
||||
output: ToolExecuteOutput
|
||||
) => Promise<void>;
|
||||
} {
|
||||
const { workspaceDirectory, truncator, getSessionCache } = deps;
|
||||
|
||||
async function processFilePathForInjection(
|
||||
filePath: string,
|
||||
sessionID: string,
|
||||
output: ToolExecuteOutput
|
||||
): Promise<void> {
|
||||
const resolved = resolveFilePath(workspaceDirectory, filePath);
|
||||
if (!resolved) return;
|
||||
|
||||
const projectRoot = findProjectRoot(resolved);
|
||||
const cache = getSessionCache(sessionID);
|
||||
const home = homedir();
|
||||
|
||||
const ruleFileCandidates = findRuleFiles(projectRoot, home, resolved);
|
||||
const toInject: RuleToInject[] = [];
|
||||
|
||||
for (const candidate of ruleFileCandidates) {
|
||||
if (isDuplicateByRealPath(candidate.realPath, cache.realPaths)) continue;
|
||||
|
||||
try {
|
||||
const rawContent = readFileSync(candidate.path, "utf-8");
|
||||
const { metadata, body } = parseRuleFrontmatter(rawContent);
|
||||
|
||||
let matchReason: string;
|
||||
if (candidate.isSingleFile) {
|
||||
matchReason = "copilot-instructions (always apply)";
|
||||
} else {
|
||||
const matchResult = shouldApplyRule(metadata, resolved, projectRoot);
|
||||
if (!matchResult.applies) continue;
|
||||
matchReason = matchResult.reason ?? "matched";
|
||||
}
|
||||
|
||||
const contentHash = createContentHash(body);
|
||||
if (isDuplicateByContentHash(contentHash, cache.contentHashes)) continue;
|
||||
|
||||
const relativePath = projectRoot
|
||||
? relative(projectRoot, candidate.path)
|
||||
: candidate.path;
|
||||
|
||||
toInject.push({
|
||||
relativePath,
|
||||
matchReason,
|
||||
content: body,
|
||||
distance: candidate.distance,
|
||||
});
|
||||
|
||||
cache.realPaths.add(candidate.realPath);
|
||||
cache.contentHashes.add(contentHash);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (toInject.length === 0) return;
|
||||
|
||||
toInject.sort((a, b) => a.distance - b.distance);
|
||||
|
||||
for (const rule of toInject) {
|
||||
const { result, truncated } = await truncator.truncate(
|
||||
sessionID,
|
||||
rule.content
|
||||
);
|
||||
const truncationNotice = truncated
|
||||
? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${rule.relativePath}]`
|
||||
: "";
|
||||
output.output += `\n\n[Rule: ${rule.relativePath}]\n[Match: ${rule.matchReason}]\n${result}${truncationNotice}`;
|
||||
}
|
||||
|
||||
saveInjectedRules(sessionID, cache);
|
||||
}
|
||||
|
||||
return { processFilePathForInjection };
|
||||
}
|
||||
44
src/hooks/sisyphus-junior-notepad/hook.ts
Normal file
44
src/hooks/sisyphus-junior-notepad/hook.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import { isCallerOrchestrator } from "../../shared/session-utils"
|
||||
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
|
||||
import { log } from "../../shared/logger"
|
||||
import { HOOK_NAME, NOTEPAD_DIRECTIVE } from "./constants"
|
||||
|
||||
export function createSisyphusJuniorNotepadHook(_ctx: PluginInput) {
|
||||
return {
|
||||
"tool.execute.before": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { args: Record<string, unknown>; message?: string }
|
||||
): Promise<void> => {
|
||||
// 1. Check if tool is task
|
||||
if (input.tool !== "task") {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Check if caller is Atlas (orchestrator)
|
||||
if (!isCallerOrchestrator(input.sessionID)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Get prompt from output.args
|
||||
const prompt = output.args.prompt as string | undefined
|
||||
if (!prompt) {
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Check for double injection
|
||||
if (prompt.includes(SYSTEM_DIRECTIVE_PREFIX)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Prepend directive
|
||||
output.args.prompt = NOTEPAD_DIRECTIVE + prompt
|
||||
|
||||
// 6. Log injection
|
||||
log(`[${HOOK_NAME}] Injected notepad directive to task`, {
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -1,45 +1,3 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { isCallerOrchestrator } from "../../shared/session-utils"
|
||||
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
|
||||
import { log } from "../../shared/logger"
|
||||
import { HOOK_NAME, NOTEPAD_DIRECTIVE } from "./constants"
|
||||
|
||||
export * from "./constants"
|
||||
|
||||
export function createSisyphusJuniorNotepadHook(ctx: PluginInput) {
|
||||
return {
|
||||
"tool.execute.before": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { args: Record<string, unknown>; message?: string }
|
||||
): Promise<void> => {
|
||||
// 1. Check if tool is task
|
||||
if (input.tool !== "task") {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Check if caller is Atlas (orchestrator)
|
||||
if (!isCallerOrchestrator(input.sessionID)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Get prompt from output.args
|
||||
const prompt = output.args.prompt as string | undefined
|
||||
if (!prompt) {
|
||||
return
|
||||
}
|
||||
|
||||
// 4. Check for double injection
|
||||
if (prompt.includes(SYSTEM_DIRECTIVE_PREFIX)) {
|
||||
return
|
||||
}
|
||||
|
||||
// 5. Prepend directive
|
||||
output.args.prompt = NOTEPAD_DIRECTIVE + prompt
|
||||
|
||||
// 6. Log injection
|
||||
log(`[${HOOK_NAME}] Injected notepad directive to task`, {
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
export { createSisyphusJuniorNotepadHook } from "./hook"
|
||||
|
||||
68
src/hooks/stop-continuation-guard/hook.ts
Normal file
68
src/hooks/stop-continuation-guard/hook.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
const HOOK_NAME = "stop-continuation-guard"
|
||||
|
||||
export interface StopContinuationGuard {
|
||||
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
|
||||
"chat.message": (input: { sessionID?: string }) => Promise<void>
|
||||
stop: (sessionID: string) => void
|
||||
isStopped: (sessionID: string) => boolean
|
||||
clear: (sessionID: string) => void
|
||||
}
|
||||
|
||||
export function createStopContinuationGuardHook(
|
||||
_ctx: PluginInput
|
||||
): StopContinuationGuard {
|
||||
const stoppedSessions = new Set<string>()
|
||||
|
||||
const stop = (sessionID: string): void => {
|
||||
stoppedSessions.add(sessionID)
|
||||
log(`[${HOOK_NAME}] Continuation stopped for session`, { sessionID })
|
||||
}
|
||||
|
||||
const isStopped = (sessionID: string): boolean => {
|
||||
return stoppedSessions.has(sessionID)
|
||||
}
|
||||
|
||||
const clear = (sessionID: string): void => {
|
||||
stoppedSessions.delete(sessionID)
|
||||
log(`[${HOOK_NAME}] Continuation guard cleared for session`, { sessionID })
|
||||
}
|
||||
|
||||
const event = async ({
|
||||
event,
|
||||
}: {
|
||||
event: { type: string; properties?: unknown }
|
||||
}): Promise<void> => {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
clear(sessionInfo.id)
|
||||
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const chatMessage = async ({
|
||||
sessionID,
|
||||
}: {
|
||||
sessionID?: string
|
||||
}): Promise<void> => {
|
||||
if (sessionID && stoppedSessions.has(sessionID)) {
|
||||
clear(sessionID)
|
||||
log(`[${HOOK_NAME}] Cleared stop state on new user message`, { sessionID })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
event,
|
||||
"chat.message": chatMessage,
|
||||
stop,
|
||||
isStopped,
|
||||
clear,
|
||||
}
|
||||
}
|
||||
@ -1,67 +1,2 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
const HOOK_NAME = "stop-continuation-guard"
|
||||
|
||||
export interface StopContinuationGuard {
|
||||
event: (input: { event: { type: string; properties?: unknown } }) => Promise<void>
|
||||
"chat.message": (input: { sessionID?: string }) => Promise<void>
|
||||
stop: (sessionID: string) => void
|
||||
isStopped: (sessionID: string) => boolean
|
||||
clear: (sessionID: string) => void
|
||||
}
|
||||
|
||||
export function createStopContinuationGuardHook(
|
||||
_ctx: PluginInput
|
||||
): StopContinuationGuard {
|
||||
const stoppedSessions = new Set<string>()
|
||||
|
||||
const stop = (sessionID: string): void => {
|
||||
stoppedSessions.add(sessionID)
|
||||
log(`[${HOOK_NAME}] Continuation stopped for session`, { sessionID })
|
||||
}
|
||||
|
||||
const isStopped = (sessionID: string): boolean => {
|
||||
return stoppedSessions.has(sessionID)
|
||||
}
|
||||
|
||||
const clear = (sessionID: string): void => {
|
||||
stoppedSessions.delete(sessionID)
|
||||
log(`[${HOOK_NAME}] Continuation guard cleared for session`, { sessionID })
|
||||
}
|
||||
|
||||
const event = async ({
|
||||
event,
|
||||
}: {
|
||||
event: { type: string; properties?: unknown }
|
||||
}): Promise<void> => {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
clear(sessionInfo.id)
|
||||
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const chatMessage = async ({
|
||||
sessionID,
|
||||
}: {
|
||||
sessionID?: string
|
||||
}): Promise<void> => {
|
||||
if (sessionID && stoppedSessions.has(sessionID)) {
|
||||
clear(sessionID)
|
||||
log(`[${HOOK_NAME}] Cleared stop state on new user message`, { sessionID })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
event,
|
||||
"chat.message": chatMessage,
|
||||
stop,
|
||||
isStopped,
|
||||
clear,
|
||||
}
|
||||
}
|
||||
export { createStopContinuationGuardHook } from "./hook"
|
||||
export type { StopContinuationGuard } from "./hook"
|
||||
|
||||
29
src/hooks/subagent-question-blocker/hook.ts
Normal file
29
src/hooks/subagent-question-blocker/hook.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import type { Hooks } from "@opencode-ai/plugin"
|
||||
import { subagentSessions } from "../../features/claude-code-session-state"
|
||||
import { log } from "../../shared"
|
||||
|
||||
export function createSubagentQuestionBlockerHook(): Hooks {
|
||||
return {
|
||||
"tool.execute.before": async (input) => {
|
||||
const toolName = input.tool?.toLowerCase()
|
||||
if (toolName !== "question" && toolName !== "askuserquestion") {
|
||||
return
|
||||
}
|
||||
|
||||
if (!subagentSessions.has(input.sessionID)) {
|
||||
return
|
||||
}
|
||||
|
||||
log("[subagent-question-blocker] Blocking question tool call from subagent session", {
|
||||
sessionID: input.sessionID,
|
||||
tool: input.tool,
|
||||
})
|
||||
|
||||
throw new Error(
|
||||
"Question tool is disabled for subagent sessions. " +
|
||||
"Subagents should complete their work autonomously without asking questions to users. " +
|
||||
"If you need clarification, return to the parent agent with your findings and uncertainties."
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -1,29 +1 @@
|
||||
import type { Hooks } from "@opencode-ai/plugin"
|
||||
import { subagentSessions } from "../../features/claude-code-session-state"
|
||||
import { log } from "../../shared"
|
||||
|
||||
export function createSubagentQuestionBlockerHook(): Hooks {
|
||||
return {
|
||||
"tool.execute.before": async (input) => {
|
||||
const toolName = input.tool?.toLowerCase()
|
||||
if (toolName !== "question" && toolName !== "askuserquestion") {
|
||||
return
|
||||
}
|
||||
|
||||
if (!subagentSessions.has(input.sessionID)) {
|
||||
return
|
||||
}
|
||||
|
||||
log("[subagent-question-blocker] Blocking question tool call from subagent session", {
|
||||
sessionID: input.sessionID,
|
||||
tool: input.tool,
|
||||
})
|
||||
|
||||
throw new Error(
|
||||
"Question tool is disabled for subagent sessions. " +
|
||||
"Subagents should complete their work autonomously without asking questions to users. " +
|
||||
"If you need clarification, return to the parent agent with your findings and uncertainties."
|
||||
)
|
||||
},
|
||||
}
|
||||
}
|
||||
export { createSubagentQuestionBlockerHook } from "./hook";
|
||||
|
||||
59
src/hooks/task-reminder/hook.ts
Normal file
59
src/hooks/task-reminder/hook.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
const TASK_TOOLS = new Set([
|
||||
"task",
|
||||
"task_create",
|
||||
"task_list",
|
||||
"task_get",
|
||||
"task_update",
|
||||
"task_delete",
|
||||
])
|
||||
const TURN_THRESHOLD = 10
|
||||
const REMINDER_MESSAGE = `
|
||||
|
||||
The task tools haven't been used recently. If you're tracking work, use task with action=create/update (or task_create/task_update) to record progress.`
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string
|
||||
sessionID: string
|
||||
callID: string
|
||||
}
|
||||
|
||||
interface ToolExecuteOutput {
|
||||
output: string
|
||||
}
|
||||
|
||||
export function createTaskReminderHook(_ctx: PluginInput) {
|
||||
const sessionCounters = new Map<string, number>()
|
||||
|
||||
const toolExecuteAfter = async (input: ToolExecuteInput, output: ToolExecuteOutput) => {
|
||||
const { tool, sessionID } = input
|
||||
const toolLower = tool.toLowerCase()
|
||||
|
||||
if (TASK_TOOLS.has(toolLower)) {
|
||||
sessionCounters.set(sessionID, 0)
|
||||
return
|
||||
}
|
||||
|
||||
const currentCount = sessionCounters.get(sessionID) ?? 0
|
||||
const newCount = currentCount + 1
|
||||
|
||||
if (newCount >= TURN_THRESHOLD) {
|
||||
output.output += REMINDER_MESSAGE
|
||||
sessionCounters.set(sessionID, 0)
|
||||
} else {
|
||||
sessionCounters.set(sessionID, newCount)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
if (event.type !== "session.deleted") return
|
||||
const props = event.properties as { info?: { id?: string } } | undefined
|
||||
const sessionId = props?.info?.id
|
||||
if (!sessionId) return
|
||||
sessionCounters.delete(sessionId)
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -1,59 +1 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
const TASK_TOOLS = new Set([
|
||||
"task",
|
||||
"task_create",
|
||||
"task_list",
|
||||
"task_get",
|
||||
"task_update",
|
||||
"task_delete",
|
||||
])
|
||||
const TURN_THRESHOLD = 10
|
||||
const REMINDER_MESSAGE = `
|
||||
|
||||
The task tools haven't been used recently. If you're tracking work, use task with action=create/update (or task_create/task_update) to record progress.`
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string
|
||||
sessionID: string
|
||||
callID: string
|
||||
}
|
||||
|
||||
interface ToolExecuteOutput {
|
||||
output: string
|
||||
}
|
||||
|
||||
export function createTaskReminderHook(_ctx: PluginInput) {
|
||||
const sessionCounters = new Map<string, number>()
|
||||
|
||||
const toolExecuteAfter = async (input: ToolExecuteInput, output: ToolExecuteOutput) => {
|
||||
const { tool, sessionID } = input
|
||||
const toolLower = tool.toLowerCase()
|
||||
|
||||
if (TASK_TOOLS.has(toolLower)) {
|
||||
sessionCounters.set(sessionID, 0)
|
||||
return
|
||||
}
|
||||
|
||||
const currentCount = sessionCounters.get(sessionID) ?? 0
|
||||
const newCount = currentCount + 1
|
||||
|
||||
if (newCount >= TURN_THRESHOLD) {
|
||||
output.output += REMINDER_MESSAGE
|
||||
sessionCounters.set(sessionID, 0)
|
||||
} else {
|
||||
sessionCounters.set(sessionID, newCount)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
if (event.type !== "session.deleted") return
|
||||
const props = event.properties as { info?: { id?: string } } | undefined
|
||||
const sessionId = props?.info?.id
|
||||
if (!sessionId) return
|
||||
sessionCounters.delete(sessionId)
|
||||
},
|
||||
}
|
||||
}
|
||||
export { createTaskReminderHook } from "./hook";
|
||||
|
||||
38
src/hooks/task-resume-info/hook.ts
Normal file
38
src/hooks/task-resume-info/hook.ts
Normal file
@ -0,0 +1,38 @@
|
||||
const TARGET_TOOLS = ["task", "Task", "task_tool", "call_omo_agent"]
|
||||
|
||||
const SESSION_ID_PATTERNS = [
|
||||
/Session ID: (ses_[a-zA-Z0-9_-]+)/,
|
||||
/session_id: (ses_[a-zA-Z0-9_-]+)/,
|
||||
/<task_metadata>\s*session_id: (ses_[a-zA-Z0-9_-]+)/,
|
||||
/sessionId: (ses_[a-zA-Z0-9_-]+)/,
|
||||
]
|
||||
|
||||
function extractSessionId(output: string): string | null {
|
||||
for (const pattern of SESSION_ID_PATTERNS) {
|
||||
const match = output.match(pattern)
|
||||
if (match) return match[1] ?? null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function createTaskResumeInfoHook() {
|
||||
const toolExecuteAfter = async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { title: string; output: string; metadata: unknown }
|
||||
) => {
|
||||
if (!TARGET_TOOLS.includes(input.tool)) return
|
||||
if (output.output.startsWith("Error:") || output.output.startsWith("Failed")) return
|
||||
if (output.output.includes("\nto continue:")) return
|
||||
|
||||
const sessionId = extractSessionId(output.output)
|
||||
if (!sessionId) return
|
||||
|
||||
output.output =
|
||||
output.output.trimEnd() +
|
||||
`\n\nto continue: task(session_id="${sessionId}", prompt="...")`
|
||||
}
|
||||
|
||||
return {
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
}
|
||||
}
|
||||
@ -1,36 +1 @@
|
||||
const TARGET_TOOLS = ["task", "Task", "task_tool", "call_omo_agent"]
|
||||
|
||||
const SESSION_ID_PATTERNS = [
|
||||
/Session ID: (ses_[a-zA-Z0-9_-]+)/,
|
||||
/session_id: (ses_[a-zA-Z0-9_-]+)/,
|
||||
/<task_metadata>\s*session_id: (ses_[a-zA-Z0-9_-]+)/,
|
||||
/sessionId: (ses_[a-zA-Z0-9_-]+)/,
|
||||
]
|
||||
|
||||
function extractSessionId(output: string): string | null {
|
||||
for (const pattern of SESSION_ID_PATTERNS) {
|
||||
const match = output.match(pattern)
|
||||
if (match) return match[1]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function createTaskResumeInfoHook() {
|
||||
const toolExecuteAfter = async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { title: string; output: string; metadata: unknown }
|
||||
) => {
|
||||
if (!TARGET_TOOLS.includes(input.tool)) return
|
||||
if (output.output.startsWith("Error:") || output.output.startsWith("Failed")) return
|
||||
if (output.output.includes("\nto continue:")) return
|
||||
|
||||
const sessionId = extractSessionId(output.output)
|
||||
if (!sessionId) return
|
||||
|
||||
output.output = output.output.trimEnd() + `\n\nto continue: task(session_id="${sessionId}", prompt="...")`
|
||||
}
|
||||
|
||||
return {
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
}
|
||||
}
|
||||
export { createTaskResumeInfoHook } from "./hook";
|
||||
|
||||
33
src/hooks/tasks-todowrite-disabler/hook.ts
Normal file
33
src/hooks/tasks-todowrite-disabler/hook.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { BLOCKED_TOOLS, REPLACEMENT_MESSAGE } from "./constants";
|
||||
|
||||
export interface TasksTodowriteDisablerConfig {
|
||||
experimental?: {
|
||||
task_system?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function createTasksTodowriteDisablerHook(
|
||||
config: TasksTodowriteDisablerConfig,
|
||||
) {
|
||||
const isTaskSystemEnabled = config.experimental?.task_system ?? false;
|
||||
|
||||
return {
|
||||
"tool.execute.before": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
_output: { args: Record<string, unknown> },
|
||||
) => {
|
||||
if (!isTaskSystemEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toolName = input.tool as string;
|
||||
if (
|
||||
BLOCKED_TOOLS.some(
|
||||
(blocked) => blocked.toLowerCase() === toolName.toLowerCase(),
|
||||
)
|
||||
) {
|
||||
throw new Error(REPLACEMENT_MESSAGE);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -1,29 +1,2 @@
|
||||
import { BLOCKED_TOOLS, REPLACEMENT_MESSAGE } from "./constants";
|
||||
|
||||
export interface TasksTodowriteDisablerConfig {
|
||||
experimental?: {
|
||||
task_system?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export function createTasksTodowriteDisablerHook(
|
||||
config: TasksTodowriteDisablerConfig,
|
||||
) {
|
||||
const isTaskSystemEnabled = config.experimental?.task_system ?? false;
|
||||
|
||||
return {
|
||||
"tool.execute.before": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { args: Record<string, unknown> },
|
||||
) => {
|
||||
if (!isTaskSystemEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const toolName = input.tool as string;
|
||||
if (BLOCKED_TOOLS.some((blocked) => blocked.toLowerCase() === toolName.toLowerCase())) {
|
||||
throw new Error(REPLACEMENT_MESSAGE);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
export { createTasksTodowriteDisablerHook } from "./hook";
|
||||
export type { TasksTodowriteDisablerConfig } from "./hook";
|
||||
|
||||
101
src/hooks/think-mode/hook.ts
Normal file
101
src/hooks/think-mode/hook.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import { detectThinkKeyword, extractPromptText } from "./detector"
|
||||
import { getHighVariant, getThinkingConfig, isAlreadyHighVariant } from "./switcher"
|
||||
import type { ThinkModeInput, ThinkModeState } from "./types"
|
||||
import { log } from "../../shared"
|
||||
|
||||
const thinkModeState = new Map<string, ThinkModeState>()
|
||||
|
||||
export function clearThinkModeState(sessionID: string): void {
|
||||
thinkModeState.delete(sessionID)
|
||||
}
|
||||
|
||||
export function createThinkModeHook() {
|
||||
return {
|
||||
"chat.params": async (output: ThinkModeInput, sessionID: string): Promise<void> => {
|
||||
const promptText = extractPromptText(output.parts)
|
||||
|
||||
const state: ThinkModeState = {
|
||||
requested: false,
|
||||
modelSwitched: false,
|
||||
thinkingConfigInjected: false,
|
||||
}
|
||||
|
||||
if (!detectThinkKeyword(promptText)) {
|
||||
thinkModeState.set(sessionID, state)
|
||||
return
|
||||
}
|
||||
|
||||
state.requested = true
|
||||
|
||||
const currentModel = output.message.model
|
||||
if (!currentModel) {
|
||||
thinkModeState.set(sessionID, state)
|
||||
return
|
||||
}
|
||||
|
||||
state.providerID = currentModel.providerID
|
||||
state.modelID = currentModel.modelID
|
||||
|
||||
if (isAlreadyHighVariant(currentModel.modelID)) {
|
||||
thinkModeState.set(sessionID, state)
|
||||
return
|
||||
}
|
||||
|
||||
const highVariant = getHighVariant(currentModel.modelID)
|
||||
const thinkingConfig = getThinkingConfig(currentModel.providerID, currentModel.modelID)
|
||||
|
||||
if (highVariant) {
|
||||
output.message.model = {
|
||||
providerID: currentModel.providerID,
|
||||
modelID: highVariant,
|
||||
}
|
||||
state.modelSwitched = true
|
||||
log("Think mode: model switched to high variant", {
|
||||
sessionID,
|
||||
from: currentModel.modelID,
|
||||
to: highVariant,
|
||||
})
|
||||
}
|
||||
|
||||
if (thinkingConfig) {
|
||||
const messageData = output.message as Record<string, unknown>
|
||||
const agentThinking = messageData.thinking as { type?: string } | undefined
|
||||
const agentProviderOptions = messageData.providerOptions
|
||||
|
||||
const agentDisabledThinking = agentThinking?.type === "disabled"
|
||||
const agentHasCustomProviderOptions = Boolean(agentProviderOptions)
|
||||
|
||||
if (agentDisabledThinking) {
|
||||
log("Think mode: skipping - agent has thinking disabled", {
|
||||
sessionID,
|
||||
provider: currentModel.providerID,
|
||||
})
|
||||
} else if (agentHasCustomProviderOptions) {
|
||||
log("Think mode: skipping - agent has custom providerOptions", {
|
||||
sessionID,
|
||||
provider: currentModel.providerID,
|
||||
})
|
||||
} else {
|
||||
Object.assign(output.message, thinkingConfig)
|
||||
state.thinkingConfigInjected = true
|
||||
log("Think mode: thinking config injected", {
|
||||
sessionID,
|
||||
provider: currentModel.providerID,
|
||||
config: thinkingConfig,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
thinkModeState.set(sessionID, state)
|
||||
},
|
||||
|
||||
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
if (event.type === "session.deleted") {
|
||||
const props = event.properties as { info?: { id?: string } } | undefined
|
||||
if (props?.info?.id) {
|
||||
thinkModeState.delete(props.info.id)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -1,108 +1,5 @@
|
||||
import { detectThinkKeyword, extractPromptText } from "./detector"
|
||||
import { getHighVariant, isAlreadyHighVariant, getThinkingConfig } from "./switcher"
|
||||
import type { ThinkModeState, ThinkModeInput } from "./types"
|
||||
import { log } from "../../shared"
|
||||
|
||||
export * from "./detector"
|
||||
export * from "./switcher"
|
||||
export * from "./types"
|
||||
|
||||
const thinkModeState = new Map<string, ThinkModeState>()
|
||||
|
||||
export function clearThinkModeState(sessionID: string): void {
|
||||
thinkModeState.delete(sessionID)
|
||||
}
|
||||
|
||||
export function createThinkModeHook() {
|
||||
return {
|
||||
"chat.params": async (
|
||||
output: ThinkModeInput,
|
||||
sessionID: string
|
||||
): Promise<void> => {
|
||||
const promptText = extractPromptText(output.parts)
|
||||
|
||||
const state: ThinkModeState = {
|
||||
requested: false,
|
||||
modelSwitched: false,
|
||||
thinkingConfigInjected: false,
|
||||
}
|
||||
|
||||
if (!detectThinkKeyword(promptText)) {
|
||||
thinkModeState.set(sessionID, state)
|
||||
return
|
||||
}
|
||||
|
||||
state.requested = true
|
||||
|
||||
const currentModel = output.message.model
|
||||
if (!currentModel) {
|
||||
thinkModeState.set(sessionID, state)
|
||||
return
|
||||
}
|
||||
|
||||
state.providerID = currentModel.providerID
|
||||
state.modelID = currentModel.modelID
|
||||
|
||||
if (isAlreadyHighVariant(currentModel.modelID)) {
|
||||
thinkModeState.set(sessionID, state)
|
||||
return
|
||||
}
|
||||
|
||||
const highVariant = getHighVariant(currentModel.modelID)
|
||||
const thinkingConfig = getThinkingConfig(currentModel.providerID, currentModel.modelID)
|
||||
|
||||
if (highVariant) {
|
||||
output.message.model = {
|
||||
providerID: currentModel.providerID,
|
||||
modelID: highVariant,
|
||||
}
|
||||
state.modelSwitched = true
|
||||
log("Think mode: model switched to high variant", {
|
||||
sessionID,
|
||||
from: currentModel.modelID,
|
||||
to: highVariant,
|
||||
})
|
||||
}
|
||||
|
||||
if (thinkingConfig) {
|
||||
const messageData = output.message as Record<string, unknown>
|
||||
const agentThinking = messageData.thinking as { type?: string } | undefined
|
||||
const agentProviderOptions = messageData.providerOptions
|
||||
|
||||
const agentDisabledThinking = agentThinking?.type === "disabled"
|
||||
const agentHasCustomProviderOptions = Boolean(agentProviderOptions)
|
||||
|
||||
if (agentDisabledThinking) {
|
||||
log("Think mode: skipping - agent has thinking disabled", {
|
||||
sessionID,
|
||||
provider: currentModel.providerID,
|
||||
})
|
||||
} else if (agentHasCustomProviderOptions) {
|
||||
log("Think mode: skipping - agent has custom providerOptions", {
|
||||
sessionID,
|
||||
provider: currentModel.providerID,
|
||||
})
|
||||
} else {
|
||||
Object.assign(output.message, thinkingConfig)
|
||||
state.thinkingConfigInjected = true
|
||||
log("Think mode: thinking config injected", {
|
||||
sessionID,
|
||||
provider: currentModel.providerID,
|
||||
config: thinkingConfig,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
thinkModeState.set(sessionID, state)
|
||||
},
|
||||
|
||||
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
if (event.type === "session.deleted") {
|
||||
const props = event.properties as { info?: { id?: string } } | undefined
|
||||
if (props?.info?.id) {
|
||||
thinkModeState.delete(props.info.id)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
export { clearThinkModeState, createThinkModeHook } from "./hook"
|
||||
|
||||
168
src/hooks/thinking-block-validator/hook.ts
Normal file
168
src/hooks/thinking-block-validator/hook.ts
Normal file
@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Proactive Thinking Block Validator Hook
|
||||
*
|
||||
* Prevents "Expected thinking/redacted_thinking but found tool_use" errors
|
||||
* by validating and fixing message structure BEFORE sending to Anthropic API.
|
||||
*
|
||||
* This hook runs on the "experimental.chat.messages.transform" hook point,
|
||||
* which is called before messages are converted to ModelMessage format and
|
||||
* sent to the API.
|
||||
*
|
||||
* Key differences from session-recovery hook:
|
||||
* - PROACTIVE (prevents error) vs REACTIVE (fixes after error)
|
||||
* - Runs BEFORE API call vs AFTER API error
|
||||
* - User never sees the error vs User sees error then recovery
|
||||
*/
|
||||
|
||||
import type { Message, Part } from "@opencode-ai/sdk"
|
||||
|
||||
interface MessageWithParts {
|
||||
info: Message
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
type MessagesTransformHook = {
|
||||
"experimental.chat.messages.transform"?: (
|
||||
input: Record<string, never>,
|
||||
output: { messages: MessageWithParts[] }
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model has extended thinking enabled
|
||||
* Uses patterns from think-mode/switcher.ts for consistency
|
||||
*/
|
||||
function isExtendedThinkingModel(modelID: string): boolean {
|
||||
if (!modelID) return false
|
||||
const lower = modelID.toLowerCase()
|
||||
|
||||
// Check for explicit thinking/high variants (always enabled)
|
||||
if (lower.includes("thinking") || lower.endsWith("-high")) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for thinking-capable models (claude-4 family, claude-3)
|
||||
// Aligns with THINKING_CAPABLE_MODELS in think-mode/switcher.ts
|
||||
return (
|
||||
lower.includes("claude-sonnet-4") ||
|
||||
lower.includes("claude-opus-4") ||
|
||||
lower.includes("claude-3")
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message has any content parts (tool_use, text, or other non-thinking content)
|
||||
*/
|
||||
function hasContentParts(parts: Part[]): boolean {
|
||||
if (!parts || parts.length === 0) return false
|
||||
|
||||
return parts.some((part: Part) => {
|
||||
const type = part.type as string
|
||||
// Include tool parts and text parts (anything that's not thinking/reasoning)
|
||||
return type === "tool" || type === "tool_use" || type === "text"
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message starts with a thinking/reasoning block
|
||||
*/
|
||||
function startsWithThinkingBlock(parts: Part[]): boolean {
|
||||
if (!parts || parts.length === 0) return false
|
||||
|
||||
const firstPart = parts[0]
|
||||
const type = firstPart.type as string
|
||||
return type === "thinking" || type === "reasoning"
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the most recent thinking content from previous assistant messages
|
||||
*/
|
||||
function findPreviousThinkingContent(
|
||||
messages: MessageWithParts[],
|
||||
currentIndex: number
|
||||
): string {
|
||||
// Search backwards from current message
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
const msg = messages[i]
|
||||
if (msg.info.role !== "assistant") continue
|
||||
|
||||
// Look for thinking parts
|
||||
if (!msg.parts) continue
|
||||
for (const part of msg.parts) {
|
||||
const type = part.type as string
|
||||
if (type === "thinking" || type === "reasoning") {
|
||||
const thinking = (part as any).thinking || (part as any).text
|
||||
if (thinking && typeof thinking === "string" && thinking.trim().length > 0) {
|
||||
return thinking
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend a thinking block to a message's parts array
|
||||
*/
|
||||
function prependThinkingBlock(message: MessageWithParts, thinkingContent: string): void {
|
||||
if (!message.parts) {
|
||||
message.parts = []
|
||||
}
|
||||
|
||||
// Create synthetic thinking part
|
||||
const thinkingPart = {
|
||||
type: "thinking" as const,
|
||||
id: `prt_0000000000_synthetic_thinking`,
|
||||
sessionID: (message.info as any).sessionID || "",
|
||||
messageID: message.info.id,
|
||||
thinking: thinkingContent,
|
||||
synthetic: true,
|
||||
}
|
||||
|
||||
// Prepend to parts array
|
||||
message.parts.unshift(thinkingPart as unknown as Part)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and fix assistant messages that have tool_use but no thinking block
|
||||
*/
|
||||
export function createThinkingBlockValidatorHook(): MessagesTransformHook {
|
||||
return {
|
||||
"experimental.chat.messages.transform": async (_input, output) => {
|
||||
const { messages } = output
|
||||
|
||||
if (!messages || messages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get the model info from the last user message
|
||||
const lastUserMessage = messages.findLast(m => m.info.role === "user")
|
||||
const modelID = (lastUserMessage?.info as any)?.modelID || ""
|
||||
|
||||
// Only process if extended thinking might be enabled
|
||||
if (!isExtendedThinkingModel(modelID)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Process all assistant messages
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i]
|
||||
|
||||
// Only check assistant messages
|
||||
if (msg.info.role !== "assistant") continue
|
||||
|
||||
// Check if message has content parts but doesn't start with thinking
|
||||
if (hasContentParts(msg.parts) && !startsWithThinkingBlock(msg.parts)) {
|
||||
// Find thinking content from previous turns
|
||||
const previousThinking = findPreviousThinkingContent(messages, i)
|
||||
|
||||
// Prepend thinking block with content from previous turn or placeholder
|
||||
const thinkingContent = previousThinking || "[Continuing from previous reasoning]"
|
||||
|
||||
prependThinkingBlock(msg, thinkingContent)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -1,171 +1 @@
|
||||
/**
|
||||
* Proactive Thinking Block Validator Hook
|
||||
*
|
||||
* Prevents "Expected thinking/redacted_thinking but found tool_use" errors
|
||||
* by validating and fixing message structure BEFORE sending to Anthropic API.
|
||||
*
|
||||
* This hook runs on the "experimental.chat.messages.transform" hook point,
|
||||
* which is called before messages are converted to ModelMessage format and
|
||||
* sent to the API.
|
||||
*
|
||||
* Key differences from session-recovery hook:
|
||||
* - PROACTIVE (prevents error) vs REACTIVE (fixes after error)
|
||||
* - Runs BEFORE API call vs AFTER API error
|
||||
* - User never sees the error vs User sees error then recovery
|
||||
*/
|
||||
|
||||
import type { Message, Part } from "@opencode-ai/sdk"
|
||||
|
||||
interface MessageWithParts {
|
||||
info: Message
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
type MessagesTransformHook = {
|
||||
"experimental.chat.messages.transform"?: (
|
||||
input: Record<string, never>,
|
||||
output: { messages: MessageWithParts[] }
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model has extended thinking enabled
|
||||
* Uses patterns from think-mode/switcher.ts for consistency
|
||||
*/
|
||||
function isExtendedThinkingModel(modelID: string): boolean {
|
||||
if (!modelID) return false
|
||||
const lower = modelID.toLowerCase()
|
||||
|
||||
// Check for explicit thinking/high variants (always enabled)
|
||||
if (lower.includes("thinking") || lower.endsWith("-high")) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for thinking-capable models (claude-4 family, claude-3)
|
||||
// Aligns with THINKING_CAPABLE_MODELS in think-mode/switcher.ts
|
||||
return (
|
||||
lower.includes("claude-sonnet-4") ||
|
||||
lower.includes("claude-opus-4") ||
|
||||
lower.includes("claude-3")
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message has any content parts (tool_use, text, or other non-thinking content)
|
||||
*/
|
||||
function hasContentParts(parts: Part[]): boolean {
|
||||
if (!parts || parts.length === 0) return false
|
||||
|
||||
return parts.some((part: Part) => {
|
||||
const type = part.type as string
|
||||
// Include tool parts and text parts (anything that's not thinking/reasoning)
|
||||
return type === "tool" || type === "tool_use" || type === "text"
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a message starts with a thinking/reasoning block
|
||||
*/
|
||||
function startsWithThinkingBlock(parts: Part[]): boolean {
|
||||
if (!parts || parts.length === 0) return false
|
||||
|
||||
const firstPart = parts[0]
|
||||
const type = firstPart.type as string
|
||||
return type === "thinking" || type === "reasoning"
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the most recent thinking content from previous assistant messages
|
||||
*/
|
||||
function findPreviousThinkingContent(
|
||||
messages: MessageWithParts[],
|
||||
currentIndex: number
|
||||
): string {
|
||||
// Search backwards from current message
|
||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
||||
const msg = messages[i]
|
||||
if (msg.info.role !== "assistant") continue
|
||||
|
||||
// Look for thinking parts
|
||||
if (!msg.parts) continue
|
||||
for (const part of msg.parts) {
|
||||
const type = part.type as string
|
||||
if (type === "thinking" || type === "reasoning") {
|
||||
const thinking = (part as any).thinking || (part as any).text
|
||||
if (thinking && typeof thinking === "string" && thinking.trim().length > 0) {
|
||||
return thinking
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepend a thinking block to a message's parts array
|
||||
*/
|
||||
function prependThinkingBlock(
|
||||
message: MessageWithParts,
|
||||
thinkingContent: string
|
||||
): void {
|
||||
if (!message.parts) {
|
||||
message.parts = []
|
||||
}
|
||||
|
||||
// Create synthetic thinking part
|
||||
const thinkingPart = {
|
||||
type: "thinking" as const,
|
||||
id: `prt_0000000000_synthetic_thinking`,
|
||||
sessionID: (message.info as any).sessionID || "",
|
||||
messageID: message.info.id,
|
||||
thinking: thinkingContent,
|
||||
synthetic: true,
|
||||
}
|
||||
|
||||
// Prepend to parts array
|
||||
message.parts.unshift(thinkingPart as unknown as Part)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and fix assistant messages that have tool_use but no thinking block
|
||||
*/
|
||||
export function createThinkingBlockValidatorHook(): MessagesTransformHook {
|
||||
return {
|
||||
"experimental.chat.messages.transform": async (_input, output) => {
|
||||
const { messages } = output
|
||||
|
||||
if (!messages || messages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Get the model info from the last user message
|
||||
const lastUserMessage = messages.findLast(m => m.info.role === "user")
|
||||
const modelID = (lastUserMessage?.info as any)?.modelID || ""
|
||||
|
||||
// Only process if extended thinking might be enabled
|
||||
if (!isExtendedThinkingModel(modelID)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Process all assistant messages
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i]
|
||||
|
||||
// Only check assistant messages
|
||||
if (msg.info.role !== "assistant") continue
|
||||
|
||||
// Check if message has content parts but doesn't start with thinking
|
||||
if (hasContentParts(msg.parts) && !startsWithThinkingBlock(msg.parts)) {
|
||||
// Find thinking content from previous turns
|
||||
const previousThinking = findPreviousThinkingContent(messages, i)
|
||||
|
||||
// Prepend thinking block with content from previous turn or placeholder
|
||||
const thinkingContent = previousThinking || "[Continuing from previous reasoning]"
|
||||
|
||||
prependThinkingBlock(msg, thinkingContent)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
export { createThinkingBlockValidatorHook } from "./hook"
|
||||
|
||||
50
src/hooks/write-existing-file-guard/hook.ts
Normal file
50
src/hooks/write-existing-file-guard/hook.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import { existsSync } from "fs"
|
||||
import { resolve, isAbsolute, join, normalize, sep } from "path"
|
||||
|
||||
import { log } from "../../shared"
|
||||
|
||||
export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
|
||||
return {
|
||||
"tool.execute.before": async (input, output) => {
|
||||
const toolName = input.tool?.toLowerCase()
|
||||
if (toolName !== "write") {
|
||||
return
|
||||
}
|
||||
|
||||
const args = output.args as
|
||||
| { filePath?: string; path?: string; file_path?: string }
|
||||
| undefined
|
||||
const filePath = args?.filePath ?? args?.path ?? args?.file_path
|
||||
if (!filePath) {
|
||||
return
|
||||
}
|
||||
|
||||
const resolvedPath = normalize(
|
||||
isAbsolute(filePath) ? filePath : resolve(ctx.directory, filePath)
|
||||
)
|
||||
|
||||
if (existsSync(resolvedPath)) {
|
||||
const sisyphusRoot = join(ctx.directory, ".sisyphus") + sep
|
||||
const isSisyphusMarkdown =
|
||||
resolvedPath.startsWith(sisyphusRoot) && resolvedPath.endsWith(".md")
|
||||
if (isSisyphusMarkdown) {
|
||||
log("[write-existing-file-guard] Allowing .sisyphus/*.md overwrite", {
|
||||
sessionID: input.sessionID,
|
||||
filePath,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log("[write-existing-file-guard] Blocking write to existing file", {
|
||||
sessionID: input.sessionID,
|
||||
filePath,
|
||||
resolvedPath,
|
||||
})
|
||||
|
||||
throw new Error("File already exists. Use edit tool instead.")
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@ -1,43 +1 @@
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
import { existsSync } from "fs"
|
||||
import { resolve, isAbsolute, join, normalize, sep } from "path"
|
||||
import { log } from "../../shared"
|
||||
|
||||
export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
|
||||
return {
|
||||
"tool.execute.before": async (input, output) => {
|
||||
const toolName = input.tool?.toLowerCase()
|
||||
if (toolName !== "write") {
|
||||
return
|
||||
}
|
||||
|
||||
const args = output.args as { filePath?: string; path?: string; file_path?: string } | undefined
|
||||
const filePath = args?.filePath ?? args?.path ?? args?.file_path
|
||||
if (!filePath) {
|
||||
return
|
||||
}
|
||||
|
||||
const resolvedPath = normalize(isAbsolute(filePath) ? filePath : resolve(ctx.directory, filePath))
|
||||
|
||||
if (existsSync(resolvedPath)) {
|
||||
const sisyphusRoot = join(ctx.directory, ".sisyphus") + sep
|
||||
const isSisyphusMarkdown = resolvedPath.startsWith(sisyphusRoot) && resolvedPath.endsWith(".md")
|
||||
if (isSisyphusMarkdown) {
|
||||
log("[write-existing-file-guard] Allowing .sisyphus/*.md overwrite", {
|
||||
sessionID: input.sessionID,
|
||||
filePath,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log("[write-existing-file-guard] Blocking write to existing file", {
|
||||
sessionID: input.sessionID,
|
||||
filePath,
|
||||
resolvedPath,
|
||||
})
|
||||
|
||||
throw new Error("File already exists. Use edit tool instead.")
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
export { createWriteExistingFileGuardHook } from "./hook"
|
||||
|
||||
@ -16,6 +16,11 @@ export * from "./claude-config-dir"
|
||||
export * from "./jsonc-parser"
|
||||
export * from "./migration"
|
||||
export * from "./opencode-config-dir"
|
||||
export type {
|
||||
OpenCodeBinaryType,
|
||||
OpenCodeConfigDirOptions,
|
||||
OpenCodeConfigPaths,
|
||||
} from "./opencode-config-dir-types"
|
||||
export * from "./opencode-version"
|
||||
export * from "./permission-compat"
|
||||
export * from "./external-plugin-detector"
|
||||
@ -28,12 +33,12 @@ 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 { resolveModelPipeline } from "./model-resolution-pipeline"
|
||||
export type {
|
||||
ModelResolutionRequest,
|
||||
ModelResolutionProvenance,
|
||||
ModelResolutionResult as ModelResolutionPipelineResult,
|
||||
} from "./model-resolution-types"
|
||||
export * from "./model-availability"
|
||||
export * from "./connected-providers-cache"
|
||||
export * from "./session-utils"
|
||||
|
||||
@ -1,36 +1,16 @@
|
||||
import { log } from "./logger"
|
||||
import { readConnectedProvidersCache } from "./connected-providers-cache"
|
||||
import { fuzzyMatchModel } from "./model-availability"
|
||||
import type { FallbackEntry } from "./model-requirements"
|
||||
import type {
|
||||
ModelResolutionRequest,
|
||||
ModelResolutionResult,
|
||||
} from "./model-resolution-types"
|
||||
|
||||
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
|
||||
}
|
||||
export type {
|
||||
ModelResolutionProvenance,
|
||||
ModelResolutionRequest,
|
||||
ModelResolutionResult,
|
||||
} from "./model-resolution-types"
|
||||
|
||||
function normalizeModel(model?: string): string | undefined {
|
||||
const trimmed = model?.trim()
|
||||
|
||||
30
src/shared/model-resolution-types.ts
Normal file
30
src/shared/model-resolution-types.ts
Normal file
@ -0,0 +1,30 @@
|
||||
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
|
||||
}
|
||||
15
src/shared/opencode-config-dir-types.ts
Normal file
15
src/shared/opencode-config-dir-types.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export type OpenCodeBinaryType = "opencode" | "opencode-desktop"
|
||||
|
||||
export type OpenCodeConfigDirOptions = {
|
||||
binary: OpenCodeBinaryType
|
||||
version?: string | null
|
||||
checkExisting?: boolean
|
||||
}
|
||||
|
||||
export type OpenCodeConfigPaths = {
|
||||
configDir: string
|
||||
configJson: string
|
||||
configJsonc: string
|
||||
packageJson: string
|
||||
omoConfig: string
|
||||
}
|
||||
@ -2,21 +2,17 @@ import { existsSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join, resolve } from "node:path"
|
||||
|
||||
export type OpenCodeBinaryType = "opencode" | "opencode-desktop"
|
||||
import type {
|
||||
OpenCodeBinaryType,
|
||||
OpenCodeConfigDirOptions,
|
||||
OpenCodeConfigPaths,
|
||||
} from "./opencode-config-dir-types"
|
||||
|
||||
export interface OpenCodeConfigDirOptions {
|
||||
binary: OpenCodeBinaryType
|
||||
version?: string | null
|
||||
checkExisting?: boolean
|
||||
}
|
||||
|
||||
export interface OpenCodeConfigPaths {
|
||||
configDir: string
|
||||
configJson: string
|
||||
configJsonc: string
|
||||
packageJson: string
|
||||
omoConfig: string
|
||||
}
|
||||
export type {
|
||||
OpenCodeBinaryType,
|
||||
OpenCodeConfigDirOptions,
|
||||
OpenCodeConfigPaths,
|
||||
} from "./opencode-config-dir-types"
|
||||
|
||||
export const TAURI_APP_IDENTIFIER = "ai.opencode.desktop"
|
||||
export const TAURI_APP_IDENTIFIER_DEV = "ai.opencode.desktop.dev"
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { spawn } from "bun"
|
||||
import type { TmuxConfig, TmuxLayout } from "../../config/schema"
|
||||
import type { SpawnPaneResult } from "./types"
|
||||
import { getTmuxPath } from "../../tools/interactive-bash/utils"
|
||||
import { getTmuxPath } from "../../tools/interactive-bash/tmux-path-resolver"
|
||||
|
||||
let serverAvailable: boolean | null = null
|
||||
let serverCheckUrl: string | null = null
|
||||
|
||||
@ -2,7 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||
import { CLI_LANGUAGES } from "./constants"
|
||||
import { runSg } from "./cli"
|
||||
import { formatSearchResult, formatReplaceResult } from "./utils"
|
||||
import { formatSearchResult, formatReplaceResult } from "./result-formatter"
|
||||
import type { CliLanguage } from "./types"
|
||||
|
||||
async function showOutputToUser(context: unknown, output: string): Promise<void> {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user