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",
|
"typescript": "^5.7.3",
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"oh-my-opencode-darwin-arm64": "3.3.0",
|
"oh-my-opencode-darwin-arm64": "3.3.1",
|
||||||
"oh-my-opencode-darwin-x64": "3.3.0",
|
"oh-my-opencode-darwin-x64": "3.3.1",
|
||||||
"oh-my-opencode-linux-arm64": "3.3.0",
|
"oh-my-opencode-linux-arm64": "3.3.1",
|
||||||
"oh-my-opencode-linux-arm64-musl": "3.3.0",
|
"oh-my-opencode-linux-arm64-musl": "3.3.1",
|
||||||
"oh-my-opencode-linux-x64": "3.3.0",
|
"oh-my-opencode-linux-x64": "3.3.1",
|
||||||
"oh-my-opencode-linux-x64-musl": "3.3.0",
|
"oh-my-opencode-linux-x64-musl": "3.3.1",
|
||||||
"oh-my-opencode-windows-x64": "3.3.0",
|
"oh-my-opencode-windows-x64": "3.3.1",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -226,19 +226,19 @@
|
|||||||
|
|
||||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
"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=="],
|
"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
|
## HOW TO ADD
|
||||||
1. Create `src/agents/my-agent.ts` exporting factory + metadata.
|
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`.
|
3. Update `AgentNameSchema` in `src/config/schema.ts`.
|
||||||
4. Register in `src/index.ts` initialization.
|
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_SYSTEM_PROMPT, getDefaultAtlasPrompt } from "./default"
|
||||||
export { ATLAS_GPT_SYSTEM_PROMPT, getGptAtlasPrompt } from "./gpt"
|
export { ATLAS_GPT_SYSTEM_PROMPT, getGptAtlasPrompt } from "./gpt"
|
||||||
export {
|
export {
|
||||||
@ -36,118 +6,9 @@ export {
|
|||||||
buildCategorySection,
|
buildCategorySection,
|
||||||
buildSkillsSection,
|
buildSkillsSection,
|
||||||
buildDecisionMatrix,
|
buildDecisionMatrix,
|
||||||
} from "./utils"
|
} from "./prompt-section-builder"
|
||||||
export { isGptModel }
|
|
||||||
|
|
||||||
const MODE: AgentMode = "primary"
|
export { createAtlasAgent, getAtlasPromptSource, getAtlasPrompt, atlasPromptMetadata } from "./agent"
|
||||||
|
export type { AtlasPromptSource, OrchestratorContext } from "./agent"
|
||||||
|
|
||||||
export type AtlasPromptSource = "default" | "gpt"
|
export { isGptModel } from "../types"
|
||||||
|
|
||||||
/**
|
|
||||||
* 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",
|
|
||||||
}
|
|
||||||
|
|||||||
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 * from "./types"
|
||||||
export { createBuiltinAgents } from "./utils"
|
export { createBuiltinAgents } from "./builtin-agents"
|
||||||
export type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder"
|
export type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder"
|
||||||
export { createSisyphusAgent } from "./sisyphus"
|
export { createSisyphusAgent } from "./sisyphus"
|
||||||
export { createOracleAgent, ORACLE_PROMPT_METADATA } from "./oracle"
|
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 { buildDefaultSisyphusJuniorPrompt } from "./default"
|
||||||
export { buildGptSisyphusJuniorPrompt } from "./gpt"
|
export { buildGptSisyphusJuniorPrompt } from "./gpt"
|
||||||
|
|
||||||
const MODE: AgentMode = "subagent"
|
export {
|
||||||
|
SISYPHUS_JUNIOR_DEFAULTS,
|
||||||
// Core tools that Sisyphus-Junior must NEVER have access to
|
getSisyphusJuniorPromptSource,
|
||||||
// Note: call_omo_agent is ALLOWED so subagents can spawn explore/librarian
|
buildSisyphusJuniorPrompt,
|
||||||
const BLOCKED_TOOLS = ["task"]
|
createSisyphusJuniorAgentWithOverrides,
|
||||||
|
} from "./agent"
|
||||||
export const SISYPHUS_JUNIOR_DEFAULTS = {
|
export type { SisyphusJuniorPromptSource } from "./agent"
|
||||||
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
|
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
|
/// <reference types="bun-types" />
|
||||||
|
|
||||||
import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test"
|
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 type { AgentConfig } from "@opencode-ai/sdk"
|
||||||
import { clearSkillCache } from "../features/opencode-skill-loader/skill-content"
|
import { clearSkillCache } from "../features/opencode-skill-loader/skill-content"
|
||||||
import * as connectedProvidersCache from "../shared/connected-providers-cache"
|
import * as connectedProvidersCache from "../shared/connected-providers-cache"
|
||||||
@ -543,7 +545,7 @@ describe("createBuiltinAgents with requiresAnyModel gating (sisyphus)", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe("buildAgent with category and skills", () => {
|
describe("buildAgent with category and skills", () => {
|
||||||
const { buildAgent } = require("./utils")
|
const { buildAgent } = require("./agent-builder")
|
||||||
const TEST_MODEL = "anthropic/claude-opus-4-6"
|
const TEST_MODEL = "anthropic/claude-opus-4-6"
|
||||||
|
|
||||||
beforeEach(() => {
|
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 { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs"
|
||||||
import {
|
import { parseJsonc, getOpenCodeConfigPaths } from "../shared"
|
||||||
parseJsonc,
|
import type {
|
||||||
getOpenCodeConfigPaths,
|
OpenCodeBinaryType,
|
||||||
type OpenCodeBinaryType,
|
OpenCodeConfigPaths,
|
||||||
type OpenCodeConfigPaths,
|
} from "../shared/opencode-config-dir-types"
|
||||||
} from "../shared"
|
|
||||||
import type { ConfigMergeResult, DetectedConfig, InstallConfig } from "./types"
|
import type { ConfigMergeResult, DetectedConfig, InstallConfig } from "./types"
|
||||||
import { generateModelConfig } from "./model-fallback"
|
import { generateModelConfig } from "./model-fallback"
|
||||||
|
|
||||||
@ -47,10 +46,6 @@ function getConfigJsonc(): string {
|
|||||||
return getConfigContext().paths.configJsonc
|
return getConfigContext().paths.configJsonc
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPackageJson(): string {
|
|
||||||
return getConfigContext().paths.packageJson
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOmoConfig(): string {
|
function getOmoConfig(): string {
|
||||||
return getConfigContext().paths.omoConfig
|
return getConfigContext().paths.omoConfig
|
||||||
}
|
}
|
||||||
@ -179,11 +174,6 @@ function isEmptyOrWhitespace(content: string): boolean {
|
|||||||
return content.trim().length === 0
|
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 {
|
function parseConfigWithError(path: string): ParseConfigResult {
|
||||||
try {
|
try {
|
||||||
const stat = statSync(path)
|
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"
|
export { getLocalVersion } from "./get-local-version"
|
||||||
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 * from "./types"
|
export * from "./types"
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { spawn } from "bun"
|
import { spawn } from "bun"
|
||||||
import type { WindowState, TmuxPaneInfo } from "./types"
|
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"
|
import { log } from "../../shared"
|
||||||
|
|
||||||
export async function queryWindowState(sourcePaneId: string): Promise<WindowState | null> {
|
export async function queryWindowState(sourcePaneId: string): Promise<WindowState | null> {
|
||||||
|
|||||||
@ -1,84 +1,7 @@
|
|||||||
/**
|
export {
|
||||||
* Pending tool metadata store.
|
clearPendingStore,
|
||||||
*
|
consumeToolMetadata,
|
||||||
* OpenCode's `fromPlugin()` wrapper always replaces the metadata returned by
|
getPendingStoreSize,
|
||||||
* plugin tools with `{ truncated, outputPath }`, discarding any sessionId,
|
storeToolMetadata,
|
||||||
* title, or custom metadata set during `execute()`.
|
} from "./store"
|
||||||
*
|
export type { PendingToolMetadata } from "./store"
|
||||||
* 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()
|
|
||||||
}
|
|
||||||
|
|||||||
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";
|
export { createAgentUsageReminderHook } from "./hook";
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
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"
|
export { createAnthropicEffortHook } from "./hook";
|
||||||
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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 "./detector"
|
||||||
export * from "./executor"
|
export * from "./executor"
|
||||||
export * from "./constants"
|
export * from "./constants"
|
||||||
export * from "./types"
|
export * from "./types"
|
||||||
|
|
||||||
const sessionProcessedCommands = new Set<string>()
|
export { createAutoSlashCommandHook } from "./hook"
|
||||||
const sessionProcessedCommandExecutions = new Set<string>()
|
export type { AutoSlashCommandHookOptions } from "./hook"
|
||||||
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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"
|
export { createBackgroundNotificationHook } from "./hook"
|
||||||
|
|
||||||
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 type { BackgroundNotificationHookConfig } from "./types"
|
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"
|
export { createCategorySkillReminderHook } from "./hook"
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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"
|
export { createCommentCheckerHooks } from "./hook"
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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"
|
export { createCompactionContextInjector } from "./hook"
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|||||||
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"
|
export type { CompactionTodoPreserver } from "./hook"
|
||||||
import { log } from "../../shared/logger"
|
export { createCompactionTodoPreserverHook } from "./hook"
|
||||||
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
|
|||||||
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 type { DelegateTaskErrorPattern, DetectedError } from "./patterns"
|
||||||
|
export { DELEGATE_TASK_ERROR_PATTERNS, detectDelegateTaskError } from "./patterns"
|
||||||
export interface DelegateTaskErrorPattern {
|
export { buildRetryGuidance } from "./guidance"
|
||||||
pattern: string
|
export { createDelegateTaskRetryHook } from "./hook"
|
||||||
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}`
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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";
|
export { createDirectoryAgentsInjectorHook } from "./hook";
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
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";
|
export { createDirectoryReadmeInjectorHook } from "./hook";
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
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"
|
export {
|
||||||
|
createEditErrorRecoveryHook,
|
||||||
/**
|
EDIT_ERROR_PATTERNS,
|
||||||
* Known Edit tool error patterns that indicate the AI made a mistake
|
EDIT_ERROR_REMINDER,
|
||||||
*/
|
} from "./hook";
|
||||||
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}`
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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 "./detector"
|
||||||
export * from "./constants"
|
export * from "./constants"
|
||||||
export * from "./types"
|
export * from "./types"
|
||||||
|
|
||||||
export function createKeywordDetectorHook(ctx: PluginInput, collector?: ContextCollector) {
|
export { createKeywordDetectorHook } from "./hook"
|
||||||
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),
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -7,13 +7,13 @@
|
|||||||
* 3. Default (Claude, etc.) → default.ts (optimized for Claude series)
|
* 3. Default (Claude, etc.) → default.ts (optimized for Claude series)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { isPlannerAgent, isGptModel, getUltraworkSource } from "./utils"
|
export { isPlannerAgent, isGptModel, getUltraworkSource } from "./source-detector"
|
||||||
export type { UltraworkSource } from "./utils"
|
export type { UltraworkSource } from "./source-detector"
|
||||||
export { ULTRAWORK_PLANNER_SECTION, getPlannerUltraworkMessage } from "./planner"
|
export { ULTRAWORK_PLANNER_SECTION, getPlannerUltraworkMessage } from "./planner"
|
||||||
export { ULTRAWORK_GPT_MESSAGE, getGptUltraworkMessage } from "./gpt5.2"
|
export { ULTRAWORK_GPT_MESSAGE, getGptUltraworkMessage } from "./gpt5.2"
|
||||||
export { ULTRAWORK_DEFAULT_MESSAGE, getDefaultUltraworkMessage } from "./default"
|
export { ULTRAWORK_DEFAULT_MESSAGE, getDefaultUltraworkMessage } from "./default"
|
||||||
|
|
||||||
import { getUltraworkSource } from "./utils"
|
import { getUltraworkSource } from "./source-detector"
|
||||||
import { getPlannerUltraworkMessage } from "./planner"
|
import { getPlannerUltraworkMessage } from "./planner"
|
||||||
import { getGptUltraworkMessage } from "./gpt5.2"
|
import { getGptUltraworkMessage } from "./gpt5.2"
|
||||||
import { getDefaultUltraworkMessage } from "./default"
|
import { getDefaultUltraworkMessage } from "./default"
|
||||||
|
|||||||
@ -36,7 +36,10 @@ export type UltraworkSource = "planner" | "gpt" | "default"
|
|||||||
/**
|
/**
|
||||||
* Determines which ultrawork message source to use.
|
* 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
|
// Priority 1: Planner agents
|
||||||
if (isPlannerAgent(agentName)) {
|
if (isPlannerAgent(agentName)) {
|
||||||
return "planner"
|
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"
|
export * from "./constants"
|
||||||
|
export { createPrometheusMdOnlyHook } from "./hook"
|
||||||
/**
|
|
||||||
* 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,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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;
|
export { createQuestionLabelTruncatorHook } from "./hook";
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
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";
|
export { createRulesInjectorHook } from "./hook";
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
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 * from "./constants"
|
||||||
|
|
||||||
export function createSisyphusJuniorNotepadHook(ctx: PluginInput) {
|
export { createSisyphusJuniorNotepadHook } from "./hook"
|
||||||
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,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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"
|
export { createStopContinuationGuardHook } from "./hook"
|
||||||
import { log } from "../../shared/logger"
|
export type { StopContinuationGuard } from "./hook"
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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"
|
export { createSubagentQuestionBlockerHook } from "./hook";
|
||||||
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."
|
|
||||||
)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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"
|
export { createTaskReminderHook } from "./hook";
|
||||||
|
|
||||||
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)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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"]
|
export { createTaskResumeInfoHook } from "./hook";
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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 { createTasksTodowriteDisablerHook } from "./hook";
|
||||||
|
export type { TasksTodowriteDisablerConfig } from "./hook";
|
||||||
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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|||||||
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 "./detector"
|
||||||
export * from "./switcher"
|
export * from "./switcher"
|
||||||
export * from "./types"
|
export * from "./types"
|
||||||
|
|
||||||
const thinkModeState = new Map<string, ThinkModeState>()
|
export { clearThinkModeState, createThinkModeHook } from "./hook"
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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 @@
|
|||||||
/**
|
export { createThinkingBlockValidatorHook } from "./hook"
|
||||||
* 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
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"
|
export { createWriteExistingFileGuardHook } from "./hook"
|
||||||
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.")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -16,6 +16,11 @@ export * from "./claude-config-dir"
|
|||||||
export * from "./jsonc-parser"
|
export * from "./jsonc-parser"
|
||||||
export * from "./migration"
|
export * from "./migration"
|
||||||
export * from "./opencode-config-dir"
|
export * from "./opencode-config-dir"
|
||||||
|
export type {
|
||||||
|
OpenCodeBinaryType,
|
||||||
|
OpenCodeConfigDirOptions,
|
||||||
|
OpenCodeConfigPaths,
|
||||||
|
} from "./opencode-config-dir-types"
|
||||||
export * from "./opencode-version"
|
export * from "./opencode-version"
|
||||||
export * from "./permission-compat"
|
export * from "./permission-compat"
|
||||||
export * from "./external-plugin-detector"
|
export * from "./external-plugin-detector"
|
||||||
@ -28,12 +33,12 @@ export * from "./system-directive"
|
|||||||
export * from "./agent-tool-restrictions"
|
export * from "./agent-tool-restrictions"
|
||||||
export * from "./model-requirements"
|
export * from "./model-requirements"
|
||||||
export * from "./model-resolver"
|
export * from "./model-resolver"
|
||||||
export {
|
export { resolveModelPipeline } from "./model-resolution-pipeline"
|
||||||
resolveModelPipeline,
|
export type {
|
||||||
type ModelResolutionRequest,
|
ModelResolutionRequest,
|
||||||
type ModelResolutionResult as ModelResolutionPipelineResult,
|
ModelResolutionProvenance,
|
||||||
type ModelResolutionProvenance,
|
ModelResolutionResult as ModelResolutionPipelineResult,
|
||||||
} from "./model-resolution-pipeline"
|
} from "./model-resolution-types"
|
||||||
export * from "./model-availability"
|
export * from "./model-availability"
|
||||||
export * from "./connected-providers-cache"
|
export * from "./connected-providers-cache"
|
||||||
export * from "./session-utils"
|
export * from "./session-utils"
|
||||||
|
|||||||
@ -1,36 +1,16 @@
|
|||||||
import { log } from "./logger"
|
import { log } from "./logger"
|
||||||
import { readConnectedProvidersCache } from "./connected-providers-cache"
|
import { readConnectedProvidersCache } from "./connected-providers-cache"
|
||||||
import { fuzzyMatchModel } from "./model-availability"
|
import { fuzzyMatchModel } from "./model-availability"
|
||||||
import type { FallbackEntry } from "./model-requirements"
|
import type {
|
||||||
|
ModelResolutionRequest,
|
||||||
|
ModelResolutionResult,
|
||||||
|
} from "./model-resolution-types"
|
||||||
|
|
||||||
export type ModelResolutionRequest = {
|
export type {
|
||||||
intent?: {
|
ModelResolutionProvenance,
|
||||||
uiSelectedModel?: string
|
ModelResolutionRequest,
|
||||||
userModel?: string
|
ModelResolutionResult,
|
||||||
categoryDefaultModel?: string
|
} from "./model-resolution-types"
|
||||||
}
|
|
||||||
constraints: {
|
|
||||||
availableModels: Set<string>
|
|
||||||
}
|
|
||||||
policy?: {
|
|
||||||
fallbackChain?: FallbackEntry[]
|
|
||||||
systemDefaultModel?: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ModelResolutionProvenance =
|
|
||||||
| "override"
|
|
||||||
| "category-default"
|
|
||||||
| "provider-fallback"
|
|
||||||
| "system-default"
|
|
||||||
|
|
||||||
export type ModelResolutionResult = {
|
|
||||||
model: string
|
|
||||||
provenance: ModelResolutionProvenance
|
|
||||||
variant?: string
|
|
||||||
attempted?: string[]
|
|
||||||
reason?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeModel(model?: string): string | undefined {
|
function normalizeModel(model?: string): string | undefined {
|
||||||
const trimmed = model?.trim()
|
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 { homedir } from "node:os"
|
||||||
import { join, resolve } from "node:path"
|
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 {
|
export type {
|
||||||
binary: OpenCodeBinaryType
|
OpenCodeBinaryType,
|
||||||
version?: string | null
|
OpenCodeConfigDirOptions,
|
||||||
checkExisting?: boolean
|
OpenCodeConfigPaths,
|
||||||
}
|
} from "./opencode-config-dir-types"
|
||||||
|
|
||||||
export interface OpenCodeConfigPaths {
|
|
||||||
configDir: string
|
|
||||||
configJson: string
|
|
||||||
configJsonc: string
|
|
||||||
packageJson: string
|
|
||||||
omoConfig: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TAURI_APP_IDENTIFIER = "ai.opencode.desktop"
|
export const TAURI_APP_IDENTIFIER = "ai.opencode.desktop"
|
||||||
export const TAURI_APP_IDENTIFIER_DEV = "ai.opencode.desktop.dev"
|
export const TAURI_APP_IDENTIFIER_DEV = "ai.opencode.desktop.dev"
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { spawn } from "bun"
|
import { spawn } from "bun"
|
||||||
import type { TmuxConfig, TmuxLayout } from "../../config/schema"
|
import type { TmuxConfig, TmuxLayout } from "../../config/schema"
|
||||||
import type { SpawnPaneResult } from "./types"
|
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 serverAvailable: boolean | null = null
|
||||||
let serverCheckUrl: string | 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 { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
|
||||||
import { CLI_LANGUAGES } from "./constants"
|
import { CLI_LANGUAGES } from "./constants"
|
||||||
import { runSg } from "./cli"
|
import { runSg } from "./cli"
|
||||||
import { formatSearchResult, formatReplaceResult } from "./utils"
|
import { formatSearchResult, formatReplaceResult } from "./result-formatter"
|
||||||
import type { CliLanguage } from "./types"
|
import type { CliLanguage } from "./types"
|
||||||
|
|
||||||
async function showOutputToUser(context: unknown, output: string): Promise<void> {
|
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