feat(prompts-core): support bundled prompt sources
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -1,13 +1,19 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { dirname, join } from "node:path"
|
import { dirname, join } from "node:path"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
import { loadPrompt, PromptFileNotFoundError, PromptPathTraversalError } from "./loader"
|
import { loadPrompt, loadPromptSync, PromptFileNotFoundError, PromptPathTraversalError } from "./loader"
|
||||||
import type { PromptSource } from "./types"
|
import type { BundledPromptSource, PromptSource } from "./types"
|
||||||
|
|
||||||
const fixtureSource: PromptSource = {
|
const fixtureSource: PromptSource = {
|
||||||
baseDir: join(dirname(fileURLToPath(import.meta.url)), "__test_fixtures__"),
|
baseDir: join(dirname(fileURLToPath(import.meta.url)), "__test_fixtures__"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bundledSource: BundledPromptSource = {
|
||||||
|
kind: "bundled",
|
||||||
|
content: "Bundled prompt body with {A}, {B}, and {C}.\n",
|
||||||
|
filePath: "packages/prompts-core/prompts/test/default.md",
|
||||||
|
}
|
||||||
|
|
||||||
class ResolverFailureError extends Error {
|
class ResolverFailureError extends Error {
|
||||||
readonly name = "ResolverFailureError"
|
readonly name = "ResolverFailureError"
|
||||||
}
|
}
|
||||||
@@ -92,6 +98,34 @@ describe("loadPrompt", () => {
|
|||||||
expect(prompt.body).toBe("GPT prompt body with Alpha and Beta.\n")
|
expect(prompt.body).toBe("GPT prompt body with Alpha and Beta.\n")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("#given bundled prompt source #then returns synchronously with multiple injections", () => {
|
||||||
|
const prompt = loadPrompt({
|
||||||
|
source: bundledSource,
|
||||||
|
name: "test-prompt",
|
||||||
|
variant: "default",
|
||||||
|
inject: [
|
||||||
|
{ placeholder: "{A}", resolver: () => "Alpha" },
|
||||||
|
{ placeholder: "{B}", resolver: () => "Beta" },
|
||||||
|
{ placeholder: "{C}", resolver: () => "Gamma" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(prompt.body).toBe("Bundled prompt body with Alpha, Beta, and Gamma.\n")
|
||||||
|
expect(prompt.filePath).toBe("packages/prompts-core/prompts/test/default.md")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given bundled prompt source #when using sync loader #then returns synchronously", () => {
|
||||||
|
const prompt = loadPromptSync({
|
||||||
|
source: bundledSource,
|
||||||
|
name: "test-prompt",
|
||||||
|
variant: "default",
|
||||||
|
inject: [{ placeholder: "{A}", resolver: () => "Alpha" }],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(prompt.body).toBe("Bundled prompt body with Alpha, {B}, and {C}.\n")
|
||||||
|
expect(prompt.filePath).toBe("packages/prompts-core/prompts/test/default.md")
|
||||||
|
})
|
||||||
|
|
||||||
test("#given injection resolver throws #then propagates the error", async () => {
|
test("#given injection resolver throws #then propagates the error", async () => {
|
||||||
const error = await captureError(() =>
|
const error = await captureError(() =>
|
||||||
loadPrompt({
|
loadPrompt({
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
import { parseFrontmatter } from "@oh-my-opencode/utils"
|
import { parseFrontmatter } from "@oh-my-opencode/utils"
|
||||||
import { readFile } from "node:fs/promises"
|
import { readFile } from "node:fs/promises"
|
||||||
import { isAbsolute, relative, resolve } from "node:path"
|
import { isAbsolute, relative, resolve } from "node:path"
|
||||||
import type { LoadedPrompt, LoadPromptInput, RuntimeInjection } from "./types"
|
import type {
|
||||||
|
LoadedPrompt,
|
||||||
|
LoadBundledPromptInput,
|
||||||
|
LoadFilesystemPromptInput,
|
||||||
|
LoadPromptInput,
|
||||||
|
RuntimeInjection,
|
||||||
|
SyncRuntimeInjection,
|
||||||
|
} from "./types"
|
||||||
|
|
||||||
export class PromptFileNotFoundError extends Error {
|
export class PromptFileNotFoundError extends Error {
|
||||||
readonly name = "PromptFileNotFoundError"
|
readonly name = "PromptFileNotFoundError"
|
||||||
@@ -27,8 +34,31 @@ export class PromptPathTraversalError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadPrompt<TFrontmatter = Record<string, unknown>>(
|
export function loadPrompt<TFrontmatter = Record<string, unknown>>(
|
||||||
|
input: LoadBundledPromptInput
|
||||||
|
): LoadedPrompt<TFrontmatter>
|
||||||
|
export function loadPrompt<TFrontmatter = Record<string, unknown>>(
|
||||||
|
input: LoadFilesystemPromptInput
|
||||||
|
): Promise<LoadedPrompt<TFrontmatter>>
|
||||||
|
export function loadPrompt<TFrontmatter = Record<string, unknown>>(
|
||||||
input: LoadPromptInput
|
input: LoadPromptInput
|
||||||
|
): LoadedPrompt<TFrontmatter> | Promise<LoadedPrompt<TFrontmatter>> {
|
||||||
|
if (isLoadBundledPromptInput(input)) return loadBundledPrompt(input)
|
||||||
|
return loadFilesystemPrompt(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadPromptSync<TFrontmatter = Record<string, unknown>>(
|
||||||
|
input: LoadBundledPromptInput
|
||||||
|
): LoadedPrompt<TFrontmatter> {
|
||||||
|
return loadBundledPrompt(input)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLoadBundledPromptInput(input: LoadPromptInput): input is LoadBundledPromptInput {
|
||||||
|
return input.source.kind === "bundled"
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFilesystemPrompt<TFrontmatter = Record<string, unknown>>(
|
||||||
|
input: LoadFilesystemPromptInput
|
||||||
): Promise<LoadedPrompt<TFrontmatter>> {
|
): Promise<LoadedPrompt<TFrontmatter>> {
|
||||||
const filePath = resolvePromptFilePath(input.source.baseDir, input.name, input.variant)
|
const filePath = resolvePromptFilePath(input.source.baseDir, input.name, input.variant)
|
||||||
const content = await readPromptFile(input.name, input.variant, filePath)
|
const content = await readPromptFile(input.name, input.variant, filePath)
|
||||||
@@ -44,6 +74,21 @@ export async function loadPrompt<TFrontmatter = Record<string, unknown>>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadBundledPrompt<TFrontmatter = Record<string, unknown>>(
|
||||||
|
input: LoadBundledPromptInput
|
||||||
|
): LoadedPrompt<TFrontmatter> {
|
||||||
|
const parsed = parseFrontmatter<TFrontmatter>(input.source.content)
|
||||||
|
const body = applyRuntimeInjectionsSync(parsed.body, input.inject ?? [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
frontmatter: parsed.data,
|
||||||
|
body,
|
||||||
|
hadFrontmatter: parsed.hadFrontmatter,
|
||||||
|
parseError: parsed.parseError,
|
||||||
|
filePath: input.source.filePath,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function resolvePromptFilePath(baseDir: string, promptName: string, variant: string): string {
|
function resolvePromptFilePath(baseDir: string, promptName: string, variant: string): string {
|
||||||
const resolvedBaseDir = resolve(baseDir)
|
const resolvedBaseDir = resolve(baseDir)
|
||||||
const filePath = resolve(resolvedBaseDir, promptName, `${variant}.md`)
|
const filePath = resolve(resolvedBaseDir, promptName, `${variant}.md`)
|
||||||
@@ -76,6 +121,17 @@ async function applyRuntimeInjections(
|
|||||||
return renderedBody
|
return renderedBody
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyRuntimeInjectionsSync(
|
||||||
|
body: string,
|
||||||
|
injections: readonly SyncRuntimeInjection[]
|
||||||
|
): string {
|
||||||
|
let renderedBody = body
|
||||||
|
for (const injection of injections) {
|
||||||
|
renderedBody = renderedBody.replaceAll(injection.placeholder, injection.resolver())
|
||||||
|
}
|
||||||
|
return renderedBody
|
||||||
|
}
|
||||||
|
|
||||||
function getErrorCode(error: Error): string | undefined {
|
function getErrorCode(error: Error): string | undefined {
|
||||||
if (!("code" in error)) return undefined
|
if (!("code" in error)) return undefined
|
||||||
return typeof error.code === "string" ? error.code : undefined
|
return typeof error.code === "string" ? error.code : undefined
|
||||||
|
|||||||
@@ -8,22 +8,45 @@ export type ModelVariant =
|
|||||||
| "opus-4-7"
|
| "opus-4-7"
|
||||||
| "minimax"
|
| "minimax"
|
||||||
|
|
||||||
export type PromptSource = {
|
export type FilesystemPromptSource = {
|
||||||
|
readonly kind?: "filesystem"
|
||||||
readonly baseDir: string
|
readonly baseDir: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type BundledPromptSource = {
|
||||||
|
readonly kind: "bundled"
|
||||||
|
readonly content: string
|
||||||
|
readonly filePath: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PromptSource = FilesystemPromptSource | BundledPromptSource
|
||||||
|
|
||||||
export type RuntimeInjection = {
|
export type RuntimeInjection = {
|
||||||
readonly placeholder: string
|
readonly placeholder: string
|
||||||
readonly resolver: () => string | Promise<string>
|
readonly resolver: () => string | Promise<string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LoadPromptInput = {
|
export type SyncRuntimeInjection = {
|
||||||
readonly source: PromptSource
|
readonly placeholder: string
|
||||||
|
readonly resolver: () => string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoadFilesystemPromptInput = {
|
||||||
|
readonly source: FilesystemPromptSource
|
||||||
readonly name: string
|
readonly name: string
|
||||||
readonly variant: string
|
readonly variant: string
|
||||||
readonly inject?: readonly RuntimeInjection[]
|
readonly inject?: readonly RuntimeInjection[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type LoadBundledPromptInput = {
|
||||||
|
readonly source: BundledPromptSource
|
||||||
|
readonly name: string
|
||||||
|
readonly variant: string
|
||||||
|
readonly inject?: readonly SyncRuntimeInjection[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LoadPromptInput = LoadFilesystemPromptInput | LoadBundledPromptInput
|
||||||
|
|
||||||
export type LoadedPrompt<TFrontmatter = Record<string, unknown>> = {
|
export type LoadedPrompt<TFrontmatter = Record<string, unknown>> = {
|
||||||
readonly frontmatter: TFrontmatter
|
readonly frontmatter: TFrontmatter
|
||||||
readonly body: string
|
readonly body: string
|
||||||
|
|||||||
Reference in New Issue
Block a user