mirror of
https://github.com/Narcooo/inkos.git
synced 2026-09-01 15:08:51 +08:00
feat(core): add editable prompt pack loader
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user