Merge pull request #4385 from code-yeongyu/refactor/prompts-core-foundation
refactor(prompts-core): add packages/prompts-core foundation with model-family detector relocation
This commit is contained in:
@@ -108,6 +108,14 @@
|
|||||||
"@oh-my-opencode/utils": "workspace:*",
|
"@oh-my-opencode/utils": "workspace:*",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"packages/prompts-core": {
|
||||||
|
"name": "@oh-my-opencode/prompts-core",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@oh-my-opencode/model-core": "workspace:*",
|
||||||
|
"@oh-my-opencode/utils": "workspace:*",
|
||||||
|
},
|
||||||
|
},
|
||||||
"packages/rules-engine": {
|
"packages/rules-engine": {
|
||||||
"name": "@oh-my-opencode/rules-engine",
|
"name": "@oh-my-opencode/rules-engine",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
@@ -209,6 +217,8 @@
|
|||||||
|
|
||||||
"@oh-my-opencode/model-core": ["@oh-my-opencode/model-core@workspace:packages/model-core"],
|
"@oh-my-opencode/model-core": ["@oh-my-opencode/model-core@workspace:packages/model-core"],
|
||||||
|
|
||||||
|
"@oh-my-opencode/prompts-core": ["@oh-my-opencode/prompts-core@workspace:packages/prompts-core"],
|
||||||
|
|
||||||
"@oh-my-opencode/rules-engine": ["@oh-my-opencode/rules-engine@workspace:packages/rules-engine"],
|
"@oh-my-opencode/rules-engine": ["@oh-my-opencode/rules-engine@workspace:packages/rules-engine"],
|
||||||
|
|
||||||
"@oh-my-opencode/utils": ["@oh-my-opencode/utils@workspace:packages/utils"],
|
"@oh-my-opencode/utils": ["@oh-my-opencode/utils@workspace:packages/utils"],
|
||||||
|
|||||||
+2
-1
@@ -11,6 +11,7 @@
|
|||||||
"packages/ast-grep-mcp",
|
"packages/ast-grep-mcp",
|
||||||
"packages/utils",
|
"packages/utils",
|
||||||
"packages/model-core",
|
"packages/model-core",
|
||||||
|
"packages/prompts-core",
|
||||||
"packages/comment-checker-core",
|
"packages/comment-checker-core",
|
||||||
"packages/hashline-core",
|
"packages/hashline-core",
|
||||||
"packages/boulder-state",
|
"packages/boulder-state",
|
||||||
@@ -49,7 +50,7 @@
|
|||||||
"prepublishOnly": "bun run clean && bun run build:lsp-tools-mcp && bun run build",
|
"prepublishOnly": "bun run clean && bun run build:lsp-tools-mcp && bun run build",
|
||||||
"test:model-capabilities": "bun test src/shared/model-capability-aliases.test.ts src/shared/model-capability-guardrails.test.ts src/shared/model-capabilities.test.ts src/cli/doctor/checks/model-resolution.test.ts --bail",
|
"test:model-capabilities": "bun test src/shared/model-capability-aliases.test.ts src/shared/model-capability-guardrails.test.ts src/shared/model-capabilities.test.ts src/cli/doctor/checks/model-resolution.test.ts --bail",
|
||||||
"typecheck": "tsgo --noEmit && bun run typecheck:packages",
|
"typecheck": "tsgo --noEmit && bun run typecheck:packages",
|
||||||
"typecheck:packages": "tsgo --noEmit -p packages/rules-engine/tsconfig.json && tsgo --noEmit -p packages/ast-grep-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-mcp/tsconfig.json && tsgo --noEmit -p packages/utils/tsconfig.json && tsgo --noEmit -p packages/model-core/tsconfig.json && tsgo --noEmit -p packages/comment-checker-core/tsconfig.json && tsgo --noEmit -p packages/hashline-core/tsconfig.json && tsgo --noEmit -p packages/boulder-state/tsconfig.json && tsgo --noEmit -p packages/agents-md-core/tsconfig.json",
|
"typecheck:packages": "tsgo --noEmit -p packages/rules-engine/tsconfig.json && tsgo --noEmit -p packages/ast-grep-core/tsconfig.json && tsgo --noEmit -p packages/ast-grep-mcp/tsconfig.json && tsgo --noEmit -p packages/utils/tsconfig.json && tsgo --noEmit -p packages/model-core/tsconfig.json && tsgo --noEmit -p packages/prompts-core/tsconfig.json && tsgo --noEmit -p packages/comment-checker-core/tsconfig.json && tsgo --noEmit -p packages/hashline-core/tsconfig.json && tsgo --noEmit -p packages/boulder-state/tsconfig.json && tsgo --noEmit -p packages/agents-md-core/tsconfig.json",
|
||||||
"typecheck:script": "tsgo --noEmit -p script/tsconfig.json",
|
"typecheck:script": "tsgo --noEmit -p script/tsconfig.json",
|
||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"build:ast-grep-mcp": "bun run --cwd packages/ast-grep-mcp build"
|
"build:ast-grep-mcp": "bun run --cwd packages/ast-grep-mcp build"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from "./model-requirements"
|
export * from "./model-requirements"
|
||||||
|
export * from "./model-family-detectors"
|
||||||
export * from "./model-capability-aliases"
|
export * from "./model-capability-aliases"
|
||||||
export * from "./model-capability-heuristics"
|
export * from "./model-capability-heuristics"
|
||||||
export * from "./model-capability-guardrails"
|
export * from "./model-capability-guardrails"
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import {
|
||||||
|
isClaudeOpus47Model,
|
||||||
|
isGeminiModel,
|
||||||
|
isGlmModel,
|
||||||
|
isGptModel,
|
||||||
|
isKimiK2Model,
|
||||||
|
isMiniMaxModel,
|
||||||
|
} from "./model-family-detectors"
|
||||||
|
|
||||||
|
describe("model family detectors", () => {
|
||||||
|
test("#given GPT model ids #then detects GPT family only", () => {
|
||||||
|
expect(isGptModel("openai/gpt-5.5")).toBe(true)
|
||||||
|
expect(isGptModel("github-copilot/gpt-4o")).toBe(true)
|
||||||
|
expect(isGptModel("openai/o3-mini")).toBe(false)
|
||||||
|
expect(isGptModel("anthropic/claude-opus-4-7")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given Gemini model ids #then detects Gemini family only", () => {
|
||||||
|
expect(isGeminiModel("google/gemini-3.1-pro")).toBe(true)
|
||||||
|
expect(isGeminiModel("google-vertex/gemini-3-flash")).toBe(true)
|
||||||
|
expect(isGeminiModel("github-copilot/gemini-3.1-pro")).toBe(true)
|
||||||
|
expect(isGeminiModel("openai/gpt-5.5")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given Kimi K2 model ids #then detects Kimi K2 family only", () => {
|
||||||
|
expect(isKimiK2Model("moonshotai/kimi-k2.6")).toBe(true)
|
||||||
|
expect(isKimiK2Model("opencode/k2p5")).toBe(true)
|
||||||
|
expect(isKimiK2Model("opencode/k2-p6")).toBe(true)
|
||||||
|
expect(isKimiK2Model("anthropic/claude-opus-4-7")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given GLM model ids #then detects GLM family only", () => {
|
||||||
|
expect(isGlmModel("z-ai/glm-5.1")).toBe(true)
|
||||||
|
expect(isGlmModel("opencode/glm-4.6v")).toBe(true)
|
||||||
|
expect(isGlmModel("google/gemini-3.1-pro")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given Claude Opus 4.7 model ids #then detects Opus 4.7 only", () => {
|
||||||
|
expect(isClaudeOpus47Model("anthropic/claude-opus-4-7")).toBe(true)
|
||||||
|
expect(isClaudeOpus47Model("anthropic/claude-opus-4.7")).toBe(true)
|
||||||
|
expect(isClaudeOpus47Model("anthropic/claude-sonnet-4-6")).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given MiniMax model ids #then detects MiniMax family only", () => {
|
||||||
|
expect(isMiniMaxModel("opencode/minimax-m2.7")).toBe(true)
|
||||||
|
expect(isMiniMaxModel("minimax-m2.7-highspeed")).toBe(true)
|
||||||
|
expect(isMiniMaxModel("moonshotai/kimi-k2.6")).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
function extractModelName(model: string): string {
|
||||||
|
return model.includes("/") ? (model.split("/").pop() ?? model) : model
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGptModel(model: string): boolean {
|
||||||
|
const modelName = extractModelName(model).toLowerCase()
|
||||||
|
return modelName.includes("gpt")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isClaudeOpus47Model(model: string): boolean {
|
||||||
|
const modelName = extractModelName(model).toLowerCase().replaceAll(".", "-")
|
||||||
|
return modelName.includes("claude-opus-4-7")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isKimiK2Model(model: string): boolean {
|
||||||
|
const modelName = extractModelName(model).toLowerCase()
|
||||||
|
if (modelName.includes("kimi")) return true
|
||||||
|
if (/k2[-.]?p[56]/.test(modelName)) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isMiniMaxModel(model: string): boolean {
|
||||||
|
const modelName = extractModelName(model).toLowerCase()
|
||||||
|
return modelName.includes("minimax")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGlmModel(model: string): boolean {
|
||||||
|
const modelName = extractModelName(model).toLowerCase()
|
||||||
|
return modelName.includes("glm")
|
||||||
|
}
|
||||||
|
|
||||||
|
const GEMINI_PROVIDERS = ["google/", "google-vertex/"] as const
|
||||||
|
|
||||||
|
export function isGeminiModel(model: string): boolean {
|
||||||
|
if (GEMINI_PROVIDERS.some((prefix) => model.startsWith(prefix))) return true
|
||||||
|
|
||||||
|
if (
|
||||||
|
model.startsWith("github-copilot/") &&
|
||||||
|
extractModelName(model).toLowerCase().startsWith("gemini")
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
|
||||||
|
const modelName = extractModelName(model).toLowerCase()
|
||||||
|
return modelName.startsWith("gemini-")
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "@oh-my-opencode/prompts-core",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"description": "Harness-agnostic markdown prompt loading and model-variant routing for oh-my-opencode.",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./index.d.ts",
|
||||||
|
"import": "./src/index.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"types": "./index.d.ts",
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsgo --noEmit -p tsconfig.json",
|
||||||
|
"test": "bun test src/*.test.ts test/*.test.ts"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@oh-my-opencode/model-core": "workspace:*",
|
||||||
|
"@oh-my-opencode/utils": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
title: Test Prompt
|
||||||
|
enabled: true
|
||||||
|
---
|
||||||
|
Default prompt body with {X}.
|
||||||
|
Second line remains verbatim.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
---
|
||||||
|
---
|
||||||
|
GPT prompt body with {A} and {B}.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export type {
|
||||||
|
LoadedPrompt,
|
||||||
|
LoadPromptInput,
|
||||||
|
ModelVariant,
|
||||||
|
PromptSource,
|
||||||
|
RuntimeInjection,
|
||||||
|
VariantTable,
|
||||||
|
} from "./types"
|
||||||
|
export { resolveVariant } from "./variant-resolver"
|
||||||
|
export type { ResolveVariantInput } from "./variant-resolver"
|
||||||
|
export { loadPrompt, PromptFileNotFoundError, PromptPathTraversalError } from "./loader"
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { dirname, join } from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
import { loadPrompt, PromptFileNotFoundError, PromptPathTraversalError } from "./loader"
|
||||||
|
import type { PromptSource } from "./types"
|
||||||
|
|
||||||
|
const fixtureSource: PromptSource = {
|
||||||
|
baseDir: join(dirname(fileURLToPath(import.meta.url)), "__test_fixtures__"),
|
||||||
|
}
|
||||||
|
|
||||||
|
class ResolverFailureError extends Error {
|
||||||
|
readonly name = "ResolverFailureError"
|
||||||
|
}
|
||||||
|
|
||||||
|
class ExpectedErrorMissingError extends Error {
|
||||||
|
readonly name = "ExpectedErrorMissingError"
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("loadPrompt", () => {
|
||||||
|
test("#given markdown fixture #then returns markdown body verbatim", async () => {
|
||||||
|
const prompt = await loadPrompt({ source: fixtureSource, name: "test-prompt", variant: "default" })
|
||||||
|
|
||||||
|
expect(prompt.body).toBe("Default prompt body with {X}.\nSecond line remains verbatim.\n")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given frontmatter fixture #then returns parsed frontmatter", async () => {
|
||||||
|
const prompt = await loadPrompt<{ readonly title: string; readonly enabled: boolean }>({
|
||||||
|
source: fixtureSource,
|
||||||
|
name: "test-prompt",
|
||||||
|
variant: "default",
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(prompt.frontmatter.title).toBe("Test Prompt")
|
||||||
|
expect(prompt.frontmatter.enabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given empty frontmatter #then parses without crashing", async () => {
|
||||||
|
const prompt = await loadPrompt({ source: fixtureSource, name: "test-prompt", variant: "gpt" })
|
||||||
|
|
||||||
|
expect(prompt.frontmatter).toEqual({})
|
||||||
|
expect(prompt.body).toBe("GPT prompt body with {A} and {B}.\n")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given missing file #then error mentions prompt name and variant", async () => {
|
||||||
|
const error = await captureError(() =>
|
||||||
|
loadPrompt({ source: fixtureSource, name: "test-prompt", variant: "missing" })
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(PromptFileNotFoundError)
|
||||||
|
expect(expectError(error).message).toContain("test-prompt/missing")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given prompt name escapes source directory #then rejects path traversal", async () => {
|
||||||
|
const error = await captureError(() =>
|
||||||
|
loadPrompt({ source: fixtureSource, name: "../test-prompt", variant: "default" })
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(PromptPathTraversalError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given variant escapes source directory #then rejects path traversal", async () => {
|
||||||
|
const error = await captureError(() =>
|
||||||
|
loadPrompt({ source: fixtureSource, name: "test-prompt", variant: "../../outside" })
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(PromptPathTraversalError)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given runtime injection #then replaces placeholder in body", async () => {
|
||||||
|
const prompt = await loadPrompt({
|
||||||
|
source: fixtureSource,
|
||||||
|
name: "test-prompt",
|
||||||
|
variant: "default",
|
||||||
|
inject: [{ placeholder: "{X}", resolver: () => "Y" }],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(prompt.body).toBe("Default prompt body with Y.\nSecond line remains verbatim.\n")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given multiple runtime injections #then applies all and ignores absent placeholders", async () => {
|
||||||
|
const prompt = await loadPrompt({
|
||||||
|
source: fixtureSource,
|
||||||
|
name: "test-prompt",
|
||||||
|
variant: "gpt",
|
||||||
|
inject: [
|
||||||
|
{ placeholder: "{A}", resolver: () => "Alpha" },
|
||||||
|
{ placeholder: "{B}", resolver: () => "Beta" },
|
||||||
|
{ placeholder: "{ABSENT}", resolver: () => "No-op" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(prompt.body).toBe("GPT prompt body with Alpha and Beta.\n")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given injection resolver throws #then propagates the error", async () => {
|
||||||
|
const error = await captureError(() =>
|
||||||
|
loadPrompt({
|
||||||
|
source: fixtureSource,
|
||||||
|
name: "test-prompt",
|
||||||
|
variant: "default",
|
||||||
|
inject: [
|
||||||
|
{
|
||||||
|
placeholder: "{X}",
|
||||||
|
resolver: () => {
|
||||||
|
throw new ResolverFailureError("resolver failed")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(ResolverFailureError)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function captureError(operation: () => Promise<unknown>): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
await operation()
|
||||||
|
return undefined
|
||||||
|
} catch (error) {
|
||||||
|
return error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectError(error: unknown): Error {
|
||||||
|
if (error instanceof Error) return error
|
||||||
|
throw new ExpectedErrorMissingError("Expected operation to throw an Error instance")
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { parseFrontmatter } from "@oh-my-opencode/utils"
|
||||||
|
import { readFile } from "node:fs/promises"
|
||||||
|
import { isAbsolute, relative, resolve } from "node:path"
|
||||||
|
import type { LoadedPrompt, LoadPromptInput, RuntimeInjection } from "./types"
|
||||||
|
|
||||||
|
export class PromptFileNotFoundError extends Error {
|
||||||
|
readonly name = "PromptFileNotFoundError"
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly promptName: string,
|
||||||
|
readonly variant: string,
|
||||||
|
readonly filePath: string,
|
||||||
|
options?: ErrorOptions
|
||||||
|
) {
|
||||||
|
super(`Prompt file not found for ${promptName}/${variant}: ${filePath}`, options)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PromptPathTraversalError extends Error {
|
||||||
|
readonly name = "PromptPathTraversalError"
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
readonly promptName: string,
|
||||||
|
readonly variant: string
|
||||||
|
) {
|
||||||
|
super(`Prompt path escapes source directory for ${promptName}/${variant}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadPrompt<TFrontmatter = Record<string, unknown>>(
|
||||||
|
input: LoadPromptInput
|
||||||
|
): Promise<LoadedPrompt<TFrontmatter>> {
|
||||||
|
const filePath = resolvePromptFilePath(input.source.baseDir, input.name, input.variant)
|
||||||
|
const content = await readPromptFile(input.name, input.variant, filePath)
|
||||||
|
const parsed = parseFrontmatter<TFrontmatter>(content)
|
||||||
|
const body = await applyRuntimeInjections(parsed.body, input.inject ?? [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
frontmatter: parsed.data,
|
||||||
|
body,
|
||||||
|
hadFrontmatter: parsed.hadFrontmatter,
|
||||||
|
parseError: parsed.parseError,
|
||||||
|
filePath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePromptFilePath(baseDir: string, promptName: string, variant: string): string {
|
||||||
|
const resolvedBaseDir = resolve(baseDir)
|
||||||
|
const filePath = resolve(resolvedBaseDir, promptName, `${variant}.md`)
|
||||||
|
const relativePath = relative(resolvedBaseDir, filePath)
|
||||||
|
if (relativePath.startsWith("..") || isAbsolute(relativePath)) {
|
||||||
|
throw new PromptPathTraversalError(promptName, variant)
|
||||||
|
}
|
||||||
|
return filePath
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readPromptFile(promptName: string, variant: string, filePath: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await readFile(filePath, "utf8")
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && getErrorCode(error) === "ENOENT") {
|
||||||
|
throw new PromptFileNotFoundError(promptName, variant, filePath, { cause: error })
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyRuntimeInjections(
|
||||||
|
body: string,
|
||||||
|
injections: readonly RuntimeInjection[]
|
||||||
|
): Promise<string> {
|
||||||
|
let renderedBody = body
|
||||||
|
for (const injection of injections) {
|
||||||
|
renderedBody = renderedBody.replaceAll(injection.placeholder, await injection.resolver())
|
||||||
|
}
|
||||||
|
return renderedBody
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorCode(error: Error): string | undefined {
|
||||||
|
if (!("code" in error)) return undefined
|
||||||
|
return typeof error.code === "string" ? error.code : undefined
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { LoadPromptInput, LoadedPrompt, PromptSource, RuntimeInjection } from "./types"
|
||||||
|
|
||||||
|
describe("prompt core types", () => {
|
||||||
|
test("#given loader input shape #then accepts source name variant and runtime injections", () => {
|
||||||
|
const source: PromptSource = { baseDir: "/tmp/prompts" }
|
||||||
|
const injection: RuntimeInjection = { placeholder: "{X}", resolver: () => "Y" }
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
source,
|
||||||
|
name: "test",
|
||||||
|
variant: "default",
|
||||||
|
inject: [injection],
|
||||||
|
} satisfies LoadPromptInput
|
||||||
|
|
||||||
|
expect(input.source.baseDir).toBe("/tmp/prompts")
|
||||||
|
expect(input.inject[0]?.placeholder).toBe("{X}")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given loaded prompt shape #then carries frontmatter and rendered body", () => {
|
||||||
|
const loaded = {
|
||||||
|
frontmatter: { title: "Fixture" },
|
||||||
|
body: "Prompt body",
|
||||||
|
hadFrontmatter: true,
|
||||||
|
parseError: false,
|
||||||
|
filePath: "/tmp/prompts/test/default.md",
|
||||||
|
} satisfies LoadedPrompt<{ readonly title: string }>
|
||||||
|
|
||||||
|
expect(loaded.frontmatter.title).toBe("Fixture")
|
||||||
|
expect(loaded.body).toBe("Prompt body")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export type ModelVariant =
|
||||||
|
| "default"
|
||||||
|
| "gpt"
|
||||||
|
| "gemini"
|
||||||
|
| "kimi"
|
||||||
|
| "glm"
|
||||||
|
| "planner"
|
||||||
|
| "opus-4-7"
|
||||||
|
| "minimax"
|
||||||
|
|
||||||
|
export type PromptSource = {
|
||||||
|
readonly baseDir: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RuntimeInjection = {
|
||||||
|
readonly placeholder: string
|
||||||
|
readonly resolver: () => string | Promise<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoadPromptInput = {
|
||||||
|
readonly source: PromptSource
|
||||||
|
readonly name: string
|
||||||
|
readonly variant: string
|
||||||
|
readonly inject?: readonly RuntimeInjection[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoadedPrompt<TFrontmatter = Record<string, unknown>> = {
|
||||||
|
readonly frontmatter: TFrontmatter
|
||||||
|
readonly body: string
|
||||||
|
readonly hadFrontmatter: boolean
|
||||||
|
readonly parseError: boolean
|
||||||
|
readonly filePath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type VariantTable = Readonly<Record<string, PromptSource>>
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { PromptSource, VariantTable } from "./types"
|
||||||
|
import { resolveVariant } from "./variant-resolver"
|
||||||
|
|
||||||
|
const promptSource = (baseDir: string): PromptSource => ({ baseDir })
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
planner: promptSource("/prompts/planner"),
|
||||||
|
gpt: promptSource("/prompts/gpt"),
|
||||||
|
gemini: promptSource("/prompts/gemini"),
|
||||||
|
kimi: promptSource("/prompts/kimi"),
|
||||||
|
glm: promptSource("/prompts/glm"),
|
||||||
|
default: promptSource("/prompts/default"),
|
||||||
|
} satisfies VariantTable
|
||||||
|
|
||||||
|
describe("resolveVariant", () => {
|
||||||
|
test("#given Claude Opus 4.7 model #then resolves default variant", () => {
|
||||||
|
expect(resolveVariant({ modelID: "claude-opus-4-7", variants })).toBe("default")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given GPT model #then resolves gpt variant", () => {
|
||||||
|
expect(resolveVariant({ modelID: "gpt-5-5", variants })).toBe("gpt")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given Gemini model #then resolves gemini variant", () => {
|
||||||
|
expect(resolveVariant({ modelID: "gemini-3-1-pro", variants })).toBe("gemini")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given Kimi K2 model #then resolves kimi variant", () => {
|
||||||
|
expect(resolveVariant({ modelID: "kimi-k2-6", variants })).toBe("kimi")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given GLM model #then resolves glm variant", () => {
|
||||||
|
expect(resolveVariant({ modelID: "glm-5-1", variants })).toBe("glm")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given Prometheus agent #then planner overrides model variant", () => {
|
||||||
|
expect(resolveVariant({ agentName: "prometheus", modelID: "gpt-5-5", variants })).toBe(
|
||||||
|
"planner"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given unknown model #then falls back to default variant", () => {
|
||||||
|
expect(resolveVariant({ modelID: "claude-haiku-4-5", variants })).toBe("default")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given empty variants table #then throws TypeError", () => {
|
||||||
|
expect(() => resolveVariant({ modelID: "gpt-5-5", variants: {} })).toThrow(TypeError)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
isClaudeOpus47Model,
|
||||||
|
isGeminiModel,
|
||||||
|
isGlmModel,
|
||||||
|
isGptModel,
|
||||||
|
isKimiK2Model,
|
||||||
|
isMiniMaxModel,
|
||||||
|
} from "@oh-my-opencode/model-core"
|
||||||
|
import type { VariantTable } from "./types"
|
||||||
|
|
||||||
|
type ModelMatcher = (modelID: string) => boolean
|
||||||
|
|
||||||
|
export type ResolveVariantInput = {
|
||||||
|
readonly modelID?: string
|
||||||
|
readonly agentName?: string
|
||||||
|
readonly variants: VariantTable
|
||||||
|
}
|
||||||
|
|
||||||
|
const PLANNER_AGENT_NAMES: ReadonlySet<string> = new Set(["prometheus"] as const)
|
||||||
|
|
||||||
|
const MODEL_MATCHERS: Readonly<Record<string, ModelMatcher>> = {
|
||||||
|
gpt: isGptModel,
|
||||||
|
gemini: isGeminiModel,
|
||||||
|
kimi: isKimiK2Model,
|
||||||
|
glm: isGlmModel,
|
||||||
|
"opus-4-7": isClaudeOpus47Model,
|
||||||
|
minimax: isMiniMaxModel,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveVariant(input: ResolveVariantInput): string {
|
||||||
|
const variantNames = Object.keys(input.variants)
|
||||||
|
if (variantNames.length === 0) {
|
||||||
|
throw new TypeError("resolveVariant requires at least one prompt variant")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPlannerAgent(input.agentName) && variantNames.includes("planner")) {
|
||||||
|
return "planner"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.modelID !== undefined) {
|
||||||
|
for (const variantName of variantNames) {
|
||||||
|
if (matchesModelVariant(variantName, input.modelID)) return variantName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variantNames.includes("default")) return "default"
|
||||||
|
|
||||||
|
return variantNames[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlannerAgent(agentName: string | undefined): boolean {
|
||||||
|
return agentName !== undefined && PLANNER_AGENT_NAMES.has(agentName.toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesModelVariant(variantName: string, modelID: string): boolean {
|
||||||
|
const matcher = MODEL_MATCHERS[variantName]
|
||||||
|
return matcher?.(modelID) ?? false
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { readdir, readFile } from "node:fs/promises"
|
||||||
|
import { dirname, join } from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
|
const SOURCE_DIR = join(dirname(fileURLToPath(import.meta.url)), "../src")
|
||||||
|
|
||||||
|
describe("opencode coupling audit", () => {
|
||||||
|
test("#given prompts-core source #then no file imports @opencode-ai packages", async () => {
|
||||||
|
const offenders = await findOpenCodeImports(SOURCE_DIR)
|
||||||
|
|
||||||
|
expect(offenders).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
async function findOpenCodeImports(sourceDir: string): Promise<readonly string[]> {
|
||||||
|
const files = await collectTypeScriptFiles(sourceDir)
|
||||||
|
const offenders: string[] = []
|
||||||
|
|
||||||
|
for (const filePath of files) {
|
||||||
|
const source = await readFile(filePath, "utf8")
|
||||||
|
if (source.includes("@opencode-ai")) offenders.push(filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return offenders
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectTypeScriptFiles(directory: string): Promise<readonly string[]> {
|
||||||
|
const entries = await readdir(directory, { withFileTypes: true })
|
||||||
|
const files: string[] = []
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const entryPath = join(directory, entry.name)
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
files.push(...(await collectTypeScriptFiles(entryPath)))
|
||||||
|
} else if (entry.isFile() && entry.name.endsWith(".ts")) {
|
||||||
|
files.push(entryPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"types": ["bun-types"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*", "test/**/*"]
|
||||||
|
}
|
||||||
+9
-52
@@ -1,5 +1,14 @@
|
|||||||
import type { AgentConfig } from "@opencode-ai/sdk";
|
import type { AgentConfig } from "@opencode-ai/sdk";
|
||||||
|
|
||||||
|
export {
|
||||||
|
isClaudeOpus47Model,
|
||||||
|
isGeminiModel,
|
||||||
|
isGlmModel,
|
||||||
|
isGptModel,
|
||||||
|
isKimiK2Model,
|
||||||
|
isMiniMaxModel,
|
||||||
|
} from "@oh-my-opencode/model-core";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent mode determines UI model selection behavior:
|
* Agent mode determines UI model selection behavior:
|
||||||
* - "primary": Respects user's UI-selected model (sisyphus, atlas)
|
* - "primary": Respects user's UI-selected model (sisyphus, atlas)
|
||||||
@@ -74,11 +83,6 @@ function extractModelName(model: string): string {
|
|||||||
return model.includes("/") ? (model.split("/").pop() ?? model) : model;
|
return model.includes("/") ? (model.split("/").pop() ?? model) : model;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isGptModel(model: string): boolean {
|
|
||||||
const modelName = extractModelName(model).toLowerCase();
|
|
||||||
return modelName.includes("gpt");
|
|
||||||
}
|
|
||||||
|
|
||||||
const GPT_NATIVE_SISYPHUS_RE = /gpt-5[.-](?:[4-9]|\d{2,})/i;
|
const GPT_NATIVE_SISYPHUS_RE = /gpt-5[.-](?:[4-9]|\d{2,})/i;
|
||||||
|
|
||||||
export function isGptNativeSisyphusModel(model: string): boolean {
|
export function isGptNativeSisyphusModel(model: string): boolean {
|
||||||
@@ -101,53 +105,6 @@ export function isGpt5_2Model(model: string): boolean {
|
|||||||
return modelName.includes("gpt-5.2") || modelName.includes("gpt-5-2");
|
return modelName.includes("gpt-5.2") || modelName.includes("gpt-5-2");
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isClaudeOpus47Model(model: string): boolean {
|
|
||||||
const modelName = extractModelName(model).toLowerCase().replaceAll(".", "-");
|
|
||||||
return modelName.includes("claude-opus-4-7");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Kimi K2.x model detection (K2.5 / K2.6 family).
|
|
||||||
*
|
|
||||||
* Matches model IDs containing any of:
|
|
||||||
* - "kimi" (provider/family signal — kimi-k2.6, moonshotai/Kimi-K2.6, etc.)
|
|
||||||
* - "k2p5" / "k2-p5" / "k2.p5"
|
|
||||||
* - "k2p6" / "k2-p6" / "k2.p6"
|
|
||||||
*
|
|
||||||
* Match is case-insensitive on the model name (last path segment).
|
|
||||||
*/
|
|
||||||
export function isKimiK2Model(model: string): boolean {
|
|
||||||
const modelName = extractModelName(model).toLowerCase();
|
|
||||||
if (modelName.includes("kimi")) return true;
|
|
||||||
if (/k2[-.]?p[56]/.test(modelName)) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const GEMINI_PROVIDERS = ["google/", "google-vertex/"];
|
|
||||||
|
|
||||||
export function isMiniMaxModel(model: string): boolean {
|
|
||||||
const modelName = extractModelName(model).toLowerCase();
|
|
||||||
return modelName.includes("minimax");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isGlmModel(model: string): boolean {
|
|
||||||
const modelName = extractModelName(model).toLowerCase();
|
|
||||||
return modelName.includes("glm");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isGeminiModel(model: string): boolean {
|
|
||||||
if (GEMINI_PROVIDERS.some((prefix) => model.startsWith(prefix))) return true;
|
|
||||||
|
|
||||||
if (
|
|
||||||
model.startsWith("github-copilot/") &&
|
|
||||||
extractModelName(model).toLowerCase().startsWith("gemini")
|
|
||||||
)
|
|
||||||
return true;
|
|
||||||
|
|
||||||
const modelName = extractModelName(model).toLowerCase();
|
|
||||||
return modelName.startsWith("gemini-");
|
|
||||||
}
|
|
||||||
|
|
||||||
export type BuiltinAgentName =
|
export type BuiltinAgentName =
|
||||||
| "sisyphus"
|
| "sisyphus"
|
||||||
| "hephaestus"
|
| "hephaestus"
|
||||||
|
|||||||
Reference in New Issue
Block a user