feat(core): add editable prompt pack loader

This commit is contained in:
Ma
2026-06-26 13:41:51 +08:00
parent 6f002ad087
commit 92108c6e5b
5 changed files with 360 additions and 0 deletions
@@ -0,0 +1,78 @@
import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { describe, expect, it } from "vitest";
import {
getBuiltinPrompt,
loadPromptPackPrompt,
promptOverridePath,
} from "../skills/index.js";
async function tempProject(): Promise<string> {
return await mkdtemp(join(tmpdir(), "inkos-prompt-pack-"));
}
async function writePrompt(root: string, promptId: string, content: string): Promise<string> {
const file = promptOverridePath(root, promptId);
await mkdir(file.slice(0, file.lastIndexOf("/")), { recursive: true });
await writeFile(file, content, "utf-8");
return file;
}
describe("prompt pack loader", () => {
it("loads built-in prompts without filesystem overrides", async () => {
const loaded = await loadPromptPackPrompt({ promptId: "longform.writer" });
expect(loaded.source).toBe("builtin");
expect(loaded.content).toContain("long-form");
expect(loaded.promptId).toBe("longform.writer");
});
it("uses project override before user override and built-in", async () => {
const projectRoot = await tempProject();
const userRoot = await tempProject();
await writePrompt(userRoot, "play.renderer", "USER RENDERER");
const projectPath = await writePrompt(projectRoot, "play.renderer", "PROJECT RENDERER");
const loaded = await loadPromptPackPrompt({
promptId: "play.renderer",
projectRoot,
userRoot,
});
expect(loaded.source).toBe("project");
expect(loaded.path).toBe(projectPath);
expect(loaded.content).toBe("PROJECT RENDERER");
});
it("uses user override when project override is absent", async () => {
const userRoot = await tempProject();
const userPath = await writePrompt(userRoot, "interactive-film.story-graph", "USER GRAPH PROMPT");
const loaded = await loadPromptPackPrompt({
promptId: "interactive-film.story-graph",
projectRoot: await tempProject(),
userRoot,
});
expect(loaded.source).toBe("user");
expect(loaded.path).toBe(userPath);
expect(loaded.content).toBe("USER GRAPH PROMPT");
});
it("throws a structured error for unknown prompts", async () => {
await expect(loadPromptPackPrompt({ promptId: "missing.prompt" }))
.rejects
.toMatchObject({
code: "PROMPT_PACK_PROMPT_NOT_FOUND",
promptId: "missing.prompt",
});
});
it("can report the built-in default for reset UI", () => {
const builtin = getBuiltinPrompt("play.mutator");
expect(builtin?.source).toBe("builtin");
expect(builtin?.content).toContain("world mutation");
});
});
+12
View File
@@ -138,15 +138,27 @@ export {
} from "./models/input-governance.js";
export {
BUILTIN_CAPABILITY_SKILLS,
BUILTIN_PROMPTS,
BUILTIN_PROMPT_PACKS,
CapabilitySkillManifestSchema,
PromptPackManifestSchema,
PromptPackPromptNotFoundError,
SkillContextNeedSchema,
SkillContextRetrievalSchema,
SkillContextTierSchema,
createSkillRegistry,
getBuiltinPrompt,
listBuiltinPromptPacks,
listBuiltinPrompts,
loadPromptPackPrompt,
promptOverridePath,
type BuiltinPrompt,
type CapabilitySkillManifest,
type CreateSkillRegistryOptions,
type LoadedPromptPackPrompt,
type LoadPromptPackPromptInput,
type PromptPackManifest,
type PromptSource,
type SkillContextNeed,
type SkillContextRetrieval,
type SkillContextTier,
+163
View File
@@ -0,0 +1,163 @@
import { PromptPackManifestSchema, type PromptPackManifest } from "./types.js";
export interface BuiltinPrompt {
readonly id: string;
readonly packId: string;
readonly title: string;
readonly content: string;
}
const RAW_BUILTIN_PROMPT_PACKS: PromptPackManifest[] = [
{
id: "longform",
title: "Longform Writing",
description: "Core long-form writing prompts used by chapter production and repair.",
prompts: ["longform.writer", "longform.reviser", "longform.auditor"],
source: "builtin",
},
{
id: "play",
title: "InkOS Play",
description: "Open-world / branching interaction prompts for world mutation, rendering, reconciliation, and images.",
prompts: ["play.start", "play.mutator", "play.renderer", "play.reconciler", "play.image"],
source: "builtin",
},
{
id: "interactive-film",
title: "Interactive Film Authoring",
description: "Script, storyboard, story graph, and image-planning prompts for interactive-film projects.",
prompts: [
"interactive-film.script",
"interactive-film.storyboard",
"interactive-film.story-graph",
"interactive-film.image-plan",
],
source: "builtin",
},
];
const RAW_BUILTIN_PROMPTS: BuiltinPrompt[] = [
{
id: "longform.writer",
packId: "longform",
title: "Longform Writer",
content: [
"You are InkOS's long-form chapter writer.",
"Write prose from the governed chapter intent and selected context package.",
"Protected context is binding. Compressible context is supporting memory.",
"Do not override author intent, current focus, hard facts, or active hook evidence with genre defaults.",
].join("\n"),
},
{
id: "longform.reviser",
packId: "longform",
title: "Longform Reviser",
content: [
"You are InkOS's long-form reviser.",
"Fix the chapter according to audit issues while preserving established facts and the chapter goal.",
"If a repair requires changing higher-level state, surface that need instead of silently rewriting canon.",
].join("\n"),
},
{
id: "longform.auditor",
packId: "longform",
title: "Longform Auditor",
content: [
"You are InkOS's continuity and quality auditor.",
"Check whether the chapter follows protected intent, hard facts, active hooks, proportions, and craft requirements.",
"Report unresolved issues plainly; do not mark a failed chapter as fixed.",
].join("\n"),
},
{
id: "play.start",
packId: "play",
title: "Play Start",
content: [
"You are InkOS Play's world-start guide.",
"Help confirm the playable premise, world contract, player persona, time semantics, and visual contract before starting.",
"Do not force RPG levels or fixed stats unless the user asks for them.",
].join("\n"),
},
{
id: "play.mutator",
packId: "play",
title: "Play World Mutator",
content: [
"You are InkOS Play's world mutation engine.",
"Turn the player action into state changes: scene, entities, relationships, evidence, inventory, time, and consequences.",
"Respect the world contract and preserve actor_player as the player entity id.",
].join("\n"),
},
{
id: "play.renderer",
packId: "play",
title: "Play Scene Renderer",
content: [
"You are InkOS Play's scene renderer.",
"Render the applied world mutation as vivid interactive prose.",
"Do not invent concrete objects, evidence, or characters that are absent from applied state unless the reconciler can record them.",
].join("\n"),
},
{
id: "play.reconciler",
packId: "play",
title: "Play Scene Reconciler",
content: [
"You reconcile rendered scene prose back into the graph state.",
"Extract newly mentioned concrete entities, evidence, relationships, and locations so state does not drift from narration.",
].join("\n"),
},
{
id: "play.image",
packId: "play",
title: "Play Image Prompt",
content: [
"Create image prompts from the current play scene and visual contract.",
"Follow user-defined visual semantics. Do not add watermarks, UI frames, text overlays, or default rarity borders unless requested.",
].join("\n"),
},
{
id: "interactive-film.script",
packId: "interactive-film",
title: "Interactive Film Script",
content: [
"You are an interactive-film script writer.",
"Convert the confirmed premise/source into playable scenes, dialogue, choices, variables, and endings.",
"Leave creative space to the user; ask or preserve format constraints instead of inventing production rules.",
].join("\n"),
},
{
id: "interactive-film.storyboard",
packId: "interactive-film",
title: "Interactive Film Storyboard",
content: [
"You are an interactive-film storyboard designer.",
"Turn script beats into shot-level visual plans with clear action, composition, and image prompts.",
"Do not require video output; produce still-image/storyboard assets unless the user asks otherwise.",
].join("\n"),
},
{
id: "interactive-film.story-graph",
packId: "interactive-film",
title: "Interactive Film Story Graph",
content: [
"You are an interactive-film story graph designer.",
"Create a playable graph: nodes, choices, variables/flags, and multiple endings.",
"Every branch must remain reachable and every path should resolve to an ending.",
].join("\n"),
},
{
id: "interactive-film.image-plan",
packId: "interactive-film",
title: "Interactive Film Image Plan",
content: [
"Create image plans for interactive-film nodes and assets.",
"Use sceneKey/location continuity when available, but do not require full-screen game UI or video conversion.",
].join("\n"),
},
];
export const BUILTIN_PROMPT_PACKS: ReadonlyArray<PromptPackManifest> =
RAW_BUILTIN_PROMPT_PACKS.map((pack) => PromptPackManifestSchema.parse(pack));
export const BUILTIN_PROMPTS: ReadonlyArray<BuiltinPrompt> = RAW_BUILTIN_PROMPTS;
+12
View File
@@ -1,4 +1,16 @@
export { BUILTIN_CAPABILITY_SKILLS } from "./builtin.js";
export { BUILTIN_PROMPTS, BUILTIN_PROMPT_PACKS, type BuiltinPrompt } from "./builtin-prompts.js";
export {
PromptPackPromptNotFoundError,
getBuiltinPrompt,
listBuiltinPromptPacks,
listBuiltinPrompts,
loadPromptPackPrompt,
promptOverridePath,
type LoadedPromptPackPrompt,
type LoadPromptPackPromptInput,
type PromptSource,
} from "./prompt-pack.js";
export { createSkillRegistry, type CreateSkillRegistryOptions } from "./registry.js";
export {
CapabilitySkillManifestSchema,
+95
View File
@@ -0,0 +1,95 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { BUILTIN_PROMPTS, BUILTIN_PROMPT_PACKS, type BuiltinPrompt } from "./builtin-prompts.js";
export type PromptSource = "project" | "user" | "builtin";
export interface LoadedPromptPackPrompt {
readonly promptId: string;
readonly content: string;
readonly source: PromptSource;
readonly path?: string;
readonly title?: string;
readonly packId?: string;
}
export interface LoadPromptPackPromptInput {
readonly promptId: string;
readonly projectRoot?: string;
readonly userRoot?: string;
}
export class PromptPackPromptNotFoundError extends Error {
readonly code = "PROMPT_PACK_PROMPT_NOT_FOUND";
constructor(readonly promptId: string) {
super(`Prompt pack prompt not found: ${promptId}`);
this.name = "PromptPackPromptNotFoundError";
}
}
const BUILTIN_PROMPT_BY_ID = new Map(BUILTIN_PROMPTS.map((prompt) => [prompt.id, prompt]));
export function listBuiltinPromptPacks() {
return BUILTIN_PROMPT_PACKS;
}
export function listBuiltinPrompts(): ReadonlyArray<BuiltinPrompt> {
return BUILTIN_PROMPTS;
}
export function getBuiltinPrompt(promptId: string): LoadedPromptPackPrompt | undefined {
const normalized = normalizePromptId(promptId);
const prompt = BUILTIN_PROMPT_BY_ID.get(normalized);
if (!prompt) return undefined;
return {
promptId: prompt.id,
content: prompt.content,
source: "builtin",
title: prompt.title,
packId: prompt.packId,
};
}
export async function loadPromptPackPrompt(input: LoadPromptPackPromptInput): Promise<LoadedPromptPackPrompt> {
const promptId = normalizePromptId(input.promptId);
if (input.projectRoot) {
const projectPath = promptOverridePath(input.projectRoot, promptId);
const content = await readTextIfExists(projectPath);
if (content !== undefined) {
return { promptId, content, source: "project", path: projectPath };
}
}
if (input.userRoot) {
const userPath = promptOverridePath(input.userRoot, promptId);
const content = await readTextIfExists(userPath);
if (content !== undefined) {
return { promptId, content, source: "user", path: userPath };
}
}
const builtin = getBuiltinPrompt(promptId);
if (builtin) return builtin;
throw new PromptPackPromptNotFoundError(promptId);
}
export function promptOverridePath(root: string, promptId: string): string {
const normalized = normalizePromptId(promptId);
const parts = normalized.split(".");
return join(root, "prompt", ...parts.slice(0, -1), `${parts.at(-1)}.md`);
}
function normalizePromptId(promptId: string): string {
return promptId.trim().toLowerCase();
}
async function readTextIfExists(path: string): Promise<string | undefined> {
try {
return await readFile(path, "utf-8");
} catch {
return undefined;
}
}