diff --git a/bun.lock b/bun.lock index 798170796..04be30353 100644 --- a/bun.lock +++ b/bun.lock @@ -108,6 +108,14 @@ "@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": { "name": "@oh-my-opencode/rules-engine", "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/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/utils": ["@oh-my-opencode/utils@workspace:packages/utils"], diff --git a/package.json b/package.json index 873783204..7a84c1328 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "packages/ast-grep-mcp", "packages/utils", "packages/model-core", + "packages/prompts-core", "packages/comment-checker-core", "packages/hashline-core", "packages/boulder-state", @@ -49,7 +50,7 @@ "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", "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", "test": "bun test", "build:ast-grep-mcp": "bun run --cwd packages/ast-grep-mcp build" diff --git a/packages/model-core/src/index.ts b/packages/model-core/src/index.ts index 79cb65929..a7d3c9536 100644 --- a/packages/model-core/src/index.ts +++ b/packages/model-core/src/index.ts @@ -1,4 +1,5 @@ export * from "./model-requirements" +export * from "./model-family-detectors" export * from "./model-capability-aliases" export * from "./model-capability-heuristics" export * from "./model-capability-guardrails" diff --git a/packages/model-core/src/model-family-detectors.test.ts b/packages/model-core/src/model-family-detectors.test.ts new file mode 100644 index 000000000..504e646e6 --- /dev/null +++ b/packages/model-core/src/model-family-detectors.test.ts @@ -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) + }) +}) diff --git a/packages/model-core/src/model-family-detectors.ts b/packages/model-core/src/model-family-detectors.ts new file mode 100644 index 000000000..c87203d58 --- /dev/null +++ b/packages/model-core/src/model-family-detectors.ts @@ -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-") +} diff --git a/packages/prompts-core/package.json b/packages/prompts-core/package.json new file mode 100644 index 000000000..73c10187e --- /dev/null +++ b/packages/prompts-core/package.json @@ -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:*" + } +} diff --git a/packages/prompts-core/src/__test_fixtures__/test-prompt/default.md b/packages/prompts-core/src/__test_fixtures__/test-prompt/default.md new file mode 100644 index 000000000..4e4b14a3e --- /dev/null +++ b/packages/prompts-core/src/__test_fixtures__/test-prompt/default.md @@ -0,0 +1,6 @@ +--- +title: Test Prompt +enabled: true +--- +Default prompt body with {X}. +Second line remains verbatim. diff --git a/packages/prompts-core/src/__test_fixtures__/test-prompt/gpt.md b/packages/prompts-core/src/__test_fixtures__/test-prompt/gpt.md new file mode 100644 index 000000000..ff3925c5c --- /dev/null +++ b/packages/prompts-core/src/__test_fixtures__/test-prompt/gpt.md @@ -0,0 +1,3 @@ +--- +--- +GPT prompt body with {A} and {B}. diff --git a/packages/prompts-core/src/index.ts b/packages/prompts-core/src/index.ts new file mode 100644 index 000000000..639fa86a7 --- /dev/null +++ b/packages/prompts-core/src/index.ts @@ -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" diff --git a/packages/prompts-core/src/loader.test.ts b/packages/prompts-core/src/loader.test.ts new file mode 100644 index 000000000..2842ab640 --- /dev/null +++ b/packages/prompts-core/src/loader.test.ts @@ -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): Promise { + 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") +} diff --git a/packages/prompts-core/src/loader.ts b/packages/prompts-core/src/loader.ts new file mode 100644 index 000000000..df01b4579 --- /dev/null +++ b/packages/prompts-core/src/loader.ts @@ -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>( + input: LoadPromptInput +): Promise> { + const filePath = resolvePromptFilePath(input.source.baseDir, input.name, input.variant) + const content = await readPromptFile(input.name, input.variant, filePath) + const parsed = parseFrontmatter(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 { + 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 { + 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 +} diff --git a/packages/prompts-core/src/types.test.ts b/packages/prompts-core/src/types.test.ts new file mode 100644 index 000000000..50b1a4b47 --- /dev/null +++ b/packages/prompts-core/src/types.test.ts @@ -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") + }) +}) diff --git a/packages/prompts-core/src/types.ts b/packages/prompts-core/src/types.ts new file mode 100644 index 000000000..c70351455 --- /dev/null +++ b/packages/prompts-core/src/types.ts @@ -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 +} + +export type LoadPromptInput = { + readonly source: PromptSource + readonly name: string + readonly variant: string + readonly inject?: readonly RuntimeInjection[] +} + +export type LoadedPrompt> = { + readonly frontmatter: TFrontmatter + readonly body: string + readonly hadFrontmatter: boolean + readonly parseError: boolean + readonly filePath: string +} + +export type VariantTable = Readonly> diff --git a/packages/prompts-core/src/variant-resolver.test.ts b/packages/prompts-core/src/variant-resolver.test.ts new file mode 100644 index 000000000..303e3d028 --- /dev/null +++ b/packages/prompts-core/src/variant-resolver.test.ts @@ -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) + }) +}) diff --git a/packages/prompts-core/src/variant-resolver.ts b/packages/prompts-core/src/variant-resolver.ts new file mode 100644 index 000000000..605924c5f --- /dev/null +++ b/packages/prompts-core/src/variant-resolver.ts @@ -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 = new Set(["prometheus"] as const) + +const MODEL_MATCHERS: Readonly> = { + 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 +} diff --git a/packages/prompts-core/test/opencode-coupling-audit.test.ts b/packages/prompts-core/test/opencode-coupling-audit.test.ts new file mode 100644 index 000000000..4205fdc92 --- /dev/null +++ b/packages/prompts-core/test/opencode-coupling-audit.test.ts @@ -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 { + 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 { + 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 +} diff --git a/packages/prompts-core/tsconfig.json b/packages/prompts-core/tsconfig.json new file mode 100644 index 000000000..6a768d440 --- /dev/null +++ b/packages/prompts-core/tsconfig.json @@ -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/**/*"] +} diff --git a/src/agents/types.ts b/src/agents/types.ts index 111cdefc0..9b582527f 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -1,5 +1,14 @@ 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: * - "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; } -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; 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"); } -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 = | "sisyphus" | "hephaestus"