refactor: center creation workflows on pi harness

This commit is contained in:
Ma
2026-08-16 11:35:00 +08:00
parent 35bb2efdb6
commit d1d6d8ec13
91 changed files with 5490 additions and 1659 deletions
+3
View File
@@ -28,6 +28,9 @@ _*.md
.inkos/
books/
worlds/
dramas/
storyboards/
translations/
inkos.json
prompt/
CLAUDE.md
@@ -576,7 +576,7 @@ describe("CLI integration", () => {
await expect(readFile(join(projectDir, ".nvmrc"), "utf-8")).resolves.toBe("22\n");
await expect(readFile(join(projectDir, ".node-version"), "utf-8")).resolves.toBe("22\n");
}, CLI_PROCESS_TIMEOUT_MS);
}, DOUBLE_CLI_INVOCATION_TEST_TIMEOUT_MS);
it("treats localhost OpenAI-compatible endpoints as API-key optional", async () => {
await stat(join(projectDir, "inkos.json")).catch(() => {
@@ -0,0 +1,199 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
createContinuationImportTool,
createFanficBookTool,
createImitationBookTool,
createSpinoffBookTool,
} from "../agent/agent-tools.js";
import { StateManager } from "../state/manager.js";
function mockPipeline() {
return {
runWithAgentContext: vi.fn(async (
context: { readonly signal?: AbortSignal },
task: () => Promise<unknown>,
) => {
context.signal?.throwIfAborted();
return task();
}),
initFanficBook: vi.fn(async () => undefined),
initSpinoffBook: vi.fn(async () => undefined),
initImitationBook: vi.fn(async () => undefined),
importChapters: vi.fn(async (input: {
bookId: string;
chapters: ReadonlyArray<{ title: string; content: string }>;
}) => ({
bookId: input.bookId,
importedCount: input.chapters.length,
totalWords: 1200,
nextChapter: input.chapters.length + 1,
})),
};
}
describe("derivative-work agent tools", () => {
let root: string;
let state: StateManager;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "inkos-derivative-tools-"));
state = new StateManager(root);
await state.saveBookConfig("harbor", {
id: "harbor",
title: "雾港账页",
platform: "tomato",
genre: "suspense",
status: "active",
language: "zh",
targetChapters: 80,
chapterWordCount: 2400,
createdAt: "2026-08-14T00:00:00.000Z",
updatedAt: "2026-08-14T00:00:00.000Z",
});
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
it("creates fanfiction from confirmed source text inside the agent context", async () => {
const pipeline = mockPipeline();
const controller = new AbortController();
const tool = createFanficBookTool(pipeline as never, root);
const result = await tool.execute("fanfic-1", {
title: "霜港来信",
sourceText: "原作中林鹿守着一座废弃灯塔。",
sourceName: "霜港正典",
mode: "canon",
targetChapters: 24,
language: "zh",
}, controller.signal);
expect(pipeline.initFanficBook).toHaveBeenCalledWith(
expect.objectContaining({
id: "霜港来信",
title: "霜港来信",
fanficMode: "canon",
targetChapters: 24,
}),
"原作中林鹿守着一座废弃灯塔。",
"霜港正典",
"canon",
);
expect(pipeline.runWithAgentContext).toHaveBeenCalledWith(
{ signal: controller.signal, activatedSkills: [] },
expect.any(Function),
);
expect(result.details).toMatchObject({
kind: "book_created",
creationKind: "fanfic",
bookId: "霜港来信",
});
});
it("inherits parent-book defaults when creating a side story", async () => {
const pipeline = mockPipeline();
const tool = createSpinoffBookTool(pipeline as never, root);
const result = await tool.execute("spinoff-1", {
title: "雨夜旧账",
parentBookId: "harbor",
direction: "老船工失踪前最后一夜",
});
expect(pipeline.initSpinoffBook).toHaveBeenCalledWith(
expect.objectContaining({
id: "雨夜旧账",
parentBookId: "harbor",
platform: "tomato",
genre: "suspense",
targetChapters: 80,
chapterWordCount: 2400,
}),
"harbor",
"老船工失踪前最后一夜",
);
expect(result.details).toMatchObject({
kind: "book_created",
creationKind: "spinoff",
parentBookId: "harbor",
});
});
it("creates an original imitation project without copying the reference plot", async () => {
const pipeline = mockPipeline();
const tool = createImitationBookTool(pipeline as never, root);
const result = await tool.execute("imitation-1", {
title: "纸灯新案",
referenceText: "雨从檐角一滴滴落下来,像一只迟疑的钟。",
sourceName: "参考散文",
storyIdea: "县城档案员调查一批被替换的死亡证明",
genre: "suspense",
});
expect(pipeline.initImitationBook).toHaveBeenCalledWith(
expect.objectContaining({ id: "纸灯新案", title: "纸灯新案" }),
"雨从檐角一滴滴落下来,像一只迟疑的钟。",
"县城档案员调查一批被替换的死亡证明",
"参考散文",
);
expect(result.details).toMatchObject({
kind: "book_created",
creationKind: "imitation",
bookId: "纸灯新案",
});
});
it("imports an uploaded manuscript into a newly created continuation book", async () => {
await mkdir(join(root, ".inkos", "uploads", "continuation"), { recursive: true });
await writeFile(
join(root, ".inkos", "uploads", "continuation", "novel.txt"),
"第一章 雨港\n\n林鹿在旧码头找到一本账簿。\n\n第二章 空号\n\n电话那头只有潮声。\n",
"utf-8",
);
const pipeline = mockPipeline();
const tool = createContinuationImportTool(pipeline as never, null, root);
const result = await tool.execute("continuation-1", {
title: "雾港续章",
sourcePath: ".inkos/uploads/continuation/novel.txt",
language: "zh",
});
expect(pipeline.importChapters).toHaveBeenCalledWith({
bookId: "雾港续章",
chapters: [
{ title: "雨港", content: "林鹿在旧码头找到一本账簿。" },
{ title: "空号", content: "电话那头只有潮声。" },
],
resumeFrom: undefined,
importMode: "continuation",
});
await expect(state.loadBookConfig("雾港续章")).resolves.toMatchObject({
id: "雾港续章",
title: "雾港续章",
});
expect(result.details).toMatchObject({
kind: "book_created",
creationKind: "continuation",
bookId: "雾港续章",
importedCount: 2,
});
});
it("rejects non-uploaded absolute continuation paths", async () => {
const pipeline = mockPipeline();
const tool = createContinuationImportTool(pipeline as never, null, root);
await expect(tool.execute("continuation-absolute", {
title: "不安全路径",
sourcePath: join(root, "novel.txt"),
})).rejects.toThrow("must be project-relative");
expect(pipeline.importChapters).not.toHaveBeenCalled();
});
});
@@ -6,9 +6,11 @@ import {
createPlayStartTool,
createPlayReviseTool,
createPlayStepTool,
type PlayStartToolOptions,
} from "../agent/agent-tools.js";
import { PlayStore } from "../play/play-store.js";
import type { PlayReplayResult, PlayStepResult } from "../play/play-runner.js";
import type { PlayGraphDB } from "../play/play-db-factory.js";
const STEP_RESULT: PlayStepResult = {
sceneText: "你翻开账本,发现最后一页夹着一张旧船票。",
@@ -52,6 +54,46 @@ function pipelineStub() {
} as any;
}
function seedReadyGraph(db: PlayGraphDB): void {
db.upsertEntity({ id: "actor_player", type: "actor", label: "玩家", summary: "当前玩家。" });
db.upsertEntity({ id: "location_opening", type: "location", label: "开场地点", summary: "第一幕所在地点。" });
}
function readyRunnerFactory() {
return ({ db }: { readonly db: PlayGraphDB }) => ({
seedOpening: vi.fn(async () => {
seedReadyGraph(db);
return {
mutation: {
eventId: "evt-0",
turn: 0,
actionKind: "look" as const,
summary: "播种开场状态。",
entities: { upsert: [] },
edges: { upsert: [], expire: [] },
stateSlots: { upsert: [] },
evidence: { transitions: [] },
blocked: false,
blockedReason: "",
notes: [],
},
};
}),
});
}
function createReadyPlayStartTool(
root: string,
sessionId: string,
playMode?: "open" | "guided",
options: PlayStartToolOptions = {},
) {
return createPlayStartTool(pipelineStub(), root, sessionId, playMode, {
...options,
runnerFactory: options.runnerFactory ?? readyRunnerFactory(),
});
}
describe("agent play tools", () => {
let root: string;
@@ -65,7 +107,7 @@ describe("agent play tools", () => {
it("binds the new play world to the chat session and persists the opening scene", async () => {
const sessionId = "1700000000000-aaaa01";
const tool = createPlayStartTool(null, root, sessionId);
const tool = createReadyPlayStartTool(root, sessionId);
const result = await tool.execute("tc-start", {
title: "雨夜茶馆",
premise: "玩家扮演欠债茶馆老板,雨夜有人带着账本上门。",
@@ -103,7 +145,7 @@ describe("agent play tools", () => {
it("persists confirmed natural-language contracts from play_start", async () => {
const sessionId = "1700000000000-contract";
const tool = createPlayStartTool(null, root, sessionId);
const tool = createReadyPlayStartTool(root, sessionId);
const result = await tool.execute("tc-start-contract", {
title: "雾港修行录",
premise: "玩家是港口小宗门外门弟子,今晚要护送一只来历不明的铜匣。",
@@ -129,7 +171,7 @@ describe("agent play tools", () => {
it("uses confirmed action-payload contracts over model tool params", async () => {
const sessionId = "1700000000000-contract-payload";
const tool = createPlayStartTool(null, root, sessionId, undefined, {
const tool = createReadyPlayStartTool(root, sessionId, undefined, {
actionPayload: {
playStart: {
title: "确认卡世界",
@@ -160,7 +202,7 @@ describe("agent play tools", () => {
it("normalizes object-shaped suggested actions at the tool boundary", async () => {
const sessionId = "1700000000000-sug001";
const tool = createPlayStartTool(null, root, sessionId);
const tool = createReadyPlayStartTool(root, sessionId);
const result = await tool.execute("tc-start-suggestions", {
title: "老邮局",
premise: "玩家在地下分拣室值夜班。",
@@ -194,7 +236,12 @@ describe("agent play tools", () => {
notes: [],
},
}));
const runnerFactory = vi.fn(() => ({ seedOpening }));
const runnerFactory = vi.fn(({ db }: { readonly db: PlayGraphDB }) => ({
seedOpening: async (...args: Parameters<typeof seedOpening>) => {
seedReadyGraph(db);
return seedOpening(...args);
},
}));
const tool = createPlayStartTool(pipelineStub(), root, sessionId, undefined, { runnerFactory });
const result = await tool.execute("tc-start-seed", {
@@ -218,6 +265,48 @@ describe("agent play tools", () => {
});
});
it("refuses to create a play world without the Pi worker pipeline", async () => {
const sessionId = "1700000000000-no-pipeline";
const tool = createPlayStartTool(null, root, sessionId);
await expect(tool.execute("tc-start-no-pipeline", {
title: "不能伪成功的世界",
premise: "没有模型管线时不应创建。",
initialScene: "这段文字不能被当成成功产物。",
})).rejects.toThrow("pipeline");
await expect(new PlayStore(root).loadWorld(sessionId)).resolves.toBeNull();
});
it("removes a new world when opening seeding does not produce a usable graph", async () => {
const sessionId = "1700000000000-empty-graph";
const tool = createPlayStartTool(pipelineStub(), root, sessionId, undefined, {
runnerFactory: () => ({
seedOpening: vi.fn(async () => ({
mutation: {
eventId: "evt-0",
turn: 0,
actionKind: "look" as const,
summary: "模型没有提交任何实体。",
entities: { upsert: [] },
edges: { upsert: [], expire: [] },
stateSlots: { upsert: [] },
evidence: { transitions: [] },
blocked: false,
blockedReason: "",
notes: [],
},
})),
}),
});
await expect(tool.execute("tc-start-empty-graph", {
title: "空图谱世界",
premise: "播种失败不能广播成功。",
initialScene: "门外有人敲了三下。",
})).rejects.toThrow("没有生成可用的玩家与世界图谱");
await expect(new PlayStore(root).loadWorld(sessionId)).resolves.toBeNull();
});
it("runs opening seeding inside the abort scope and does not swallow user cancellation", async () => {
const sessionId = "1700000000000-abort1";
const controller = new AbortController();
@@ -232,9 +321,14 @@ describe("agent play tools", () => {
createAgentContext: vi.fn(() => ({})),
runWithAgentContext,
};
const seedOpening = vi.fn(async () => null);
const seedOpening = vi.fn(async (_input: { sceneText: string; suggestedActions?: readonly string[] }) => null);
const tool = createPlayStartTool(pipeline as never, root, sessionId, undefined, {
runnerFactory: () => ({ seedOpening }),
runnerFactory: ({ db }) => ({
seedOpening: async (...args) => {
seedReadyGraph(db);
return seedOpening(...args);
},
}),
});
await tool.execute("tc-start-abort-scope", {
@@ -403,7 +497,7 @@ describe("agent play tools", () => {
it("uses the player-chosen playMode for the world, overriding the tool param", async () => {
const sessionId = "1700000000000-cccc03";
const tool = createPlayStartTool(null, root, sessionId, "guided");
const tool = createReadyPlayStartTool(root, sessionId, "guided");
await tool.execute("tc-mode", { title: "选项局", initialScene: "开场。" });
const store = new PlayStore(root);
await expect(store.loadWorld(sessionId)).resolves.toMatchObject({ mode: "guided" });
@@ -417,12 +511,12 @@ describe("agent play tools", () => {
const sessionA = "1700000000000-aaaaaa";
const sessionB = "1700000000001-bbbbbb";
await createPlayStartTool(null, root, sessionA).execute("tc-a", {
await createReadyPlayStartTool(root, sessionA).execute("tc-a", {
title: "世界A",
initialScene: "A 的开场。",
});
// World B is created AFTER A, so it is the most-recently-updated world.
await createPlayStartTool(null, root, sessionB).execute("tc-b", {
await createReadyPlayStartTool(root, sessionB).execute("tc-b", {
title: "世界B",
initialScene: "B 的开场。",
});
@@ -137,6 +137,15 @@ vi.mock("@mariozechner/pi-ai", async () => {
arguments: { action: "regenerate_last" },
},
], timestamp)
: prompt === "resync failure"
? assistant([
{
type: "toolCall",
id: "resync-failure-1",
name: "resync_chapter_state",
arguments: { chapterNumber: 1 },
},
], timestamp)
: prompt === "use tool"
? assistant([
{
@@ -857,6 +866,58 @@ describe("runAgentSession cache — bookId switch", () => {
]);
});
it("exposes exactly one derivative-work tool after its confirmation", async () => {
const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
const pipeline = {} as any;
const cases = [
["fanfic_init", "fanfic_create"],
["continuation_import", "continuation_import"],
["spinoff_create", "spinoff_create"],
["style_imitation", "imitation_create"],
] as const;
for (const [index, [requestedIntent, toolName]] of cases.entries()) {
const sessionId = `derivative-confirmed-session-${index}`;
await runAgentSession(
{
sessionId,
bookId: null,
sessionKind: "chat",
actionSource: "button",
requestedIntent,
language: "zh",
pipeline,
projectRoot,
model,
},
`确认执行 ${requestedIntent}`,
);
expect(agentInstances.at(-1).state.tools.map((tool: any) => tool.name)).toEqual([toolName]);
evictAgentCache(sessionId);
}
});
it("lets production discussions read project-local sources before confirmation", async () => {
const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
const pipeline = {} as any;
for (const sessionKind of ["script", "storyboard", "interactive-film"] as const) {
const sessionId = `project-source-${sessionKind}`;
await runAgentSession(
{ sessionId, bookId: null, sessionKind, language: "zh", pipeline, projectRoot, model },
"先读项目里的素材,只讨论,不创建",
);
expect(agentInstances.at(-1).state.tools.map((tool: any) => tool.name)).toEqual([
"propose_action",
"read",
"ingest_material",
"retrieve_material",
"use_skill",
]);
evictAgentCache(sessionId);
}
});
it("gates short and play production behind in-session confirmation proposals", async () => {
const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
const pipeline = {} as any;
@@ -959,10 +1020,44 @@ describe("runAgentSession cache — bookId switch", () => {
);
});
it("treats narrative forecast cards as terminal tool answers", () => {
expect(isTerminalProductionToolName("create_narrative_forecast")).toBe(true);
expect(isTerminalProductionToolName("get_narrative_forecast")).toBe(true);
expect(isTerminalProductionToolName("select_narrative_branch")).toBe(true);
it("treats failed production tool results as terminal instead of improvising another write path", async () => {
const model = { provider: "x", id: "y", api: "anthropic-messages" } as any;
const pipeline = {
runWithAgentContext: vi.fn(async (_context: unknown, task: () => Promise<unknown>) => task()),
resyncChapterStateAndAudit: vi.fn(async () => {
throw new Error("invalid hook lifecycle state");
}),
} as any;
const result = await runAgentSession(
{ sessionId: "book-terminal-failure-session", bookId: "book-a", sessionKind: "book", language: "zh", pipeline, projectRoot, model },
"resync failure",
);
expect(pipeline.resyncChapterStateAndAudit).toHaveBeenCalledTimes(1);
expect(streamCalls).toHaveLength(1);
expect(result.messages).toEqual(
expect.arrayContaining([
expect.objectContaining({ role: "toolResult", toolName: "resync_chapter_state", isError: true }),
]),
);
});
it("treats host-owned production results as terminal tool answers", () => {
for (const toolName of [
"resync_chapter_state",
"create_narrative_forecast",
"get_narrative_forecast",
"select_narrative_branch",
"translation_create",
"fanfic_create",
"continuation_import",
"spinoff_create",
"imitation_create",
]) {
expect(isTerminalProductionToolName(toolName)).toBe(true);
}
expect(isTerminalProductionToolName("patch_chapter_text")).toBe(false);
});
it("treats play revise results as terminal instead of asking the model for extra prose", async () => {
@@ -1124,6 +1219,7 @@ describe("runAgentSession cache — bookId switch", () => {
"rename_entity",
"patch_chapter_text",
"replace_chapter_text",
"resync_chapter_state",
"delete_latest_chapter",
"research_web",
"ingest_material",
@@ -1178,6 +1274,7 @@ describe("runAgentSession cache — bookId switch", () => {
"rename_entity",
"patch_chapter_text",
"replace_chapter_text",
"resync_chapter_state",
"delete_latest_chapter",
"research_web",
"ingest_material",
@@ -1208,6 +1305,7 @@ describe("runAgentSession cache — bookId switch", () => {
"rename_entity",
"patch_chapter_text",
"replace_chapter_text",
"resync_chapter_state",
"delete_latest_chapter",
"ingest_material",
"retrieve_material",
@@ -53,32 +53,27 @@ describe("buildAgentSystemPrompt", () => {
expect(enPrompt).toContain("Do not make the next session infer missing context");
});
it("distinguishes production actions from assisted Studio workflow actions", () => {
it("treats derivative works as confirmed production actions instead of assisted routes", () => {
const prompt = buildAgentSystemPrompt(null, "zh", "chat");
expect(prompt).toContain("生产型动作");
expect(prompt).toContain("辅助入口动作");
expect(prompt).toContain("fanfic_init");
expect(prompt).toContain("continuation_import");
expect(prompt).toContain("spinoff_create");
expect(prompt).toContain("style_imitation");
expect(prompt).toContain("不能声称已经生成成品");
expect(prompt).toContain("确认后直接执行");
expect(prompt).toContain("不要求用户再到另一个表单重复填写");
expect(prompt).not.toContain("辅助入口");
});
it("maps style analysis requests to the style-imitation workflow", () => {
it("keeps pure style analysis conversational and maps actual imitation to production", () => {
const zhPrompt = buildAgentSystemPrompt(null, "zh", "chat");
const enPrompt = buildAgentSystemPrompt(null, "en", "chat");
expect(zhPrompt).toContain("文风分析");
expect(zhPrompt).toContain("先分析再仿写");
expect(zhPrompt).toContain("必须调用 propose_action");
expect(zhPrompt).toContain("仿写/文风分析/参考文风/模仿笔法=style_imitation");
expect(zhPrompt).toContain("不要用普通文字追问书名、原文、父书路径或解释流程");
expect(zhPrompt).toContain("番外/正典资料/不进入主线=spinoff_create");
expect(enPrompt).toContain("style analysis");
expect(enPrompt).toContain("analyze first then imitate");
expect(enPrompt).toContain("you must call propose_action");
expect(enPrompt).toContain("style imitation/style analysis/reference-style/prose mimicry=style_imitation");
expect(enPrompt).toContain("Do not answer by asking for a title/source text/parent-book path");
expect(enPrompt).toContain("side-story/spinoff/canon-materials=spinoff_create");
expect(zhPrompt).toContain("纯粹询问或分析文风时直接回答");
expect(zhPrompt).toContain("参考文风创作全新故事=style_imitation");
expect(zhPrompt).toContain("创建同人/续写/番外/仿写作品时调用 propose_action");
expect(enPrompt).toContain("Answer pure style-analysis questions directly");
expect(enPrompt).toContain("an original story that learns prose style from a reference=style_imitation");
expect(enPrompt).toContain("create fanfiction / continuation / side-story / style-imitation work");
});
it("adds forced skill guidance without granting execution authority", () => {
@@ -222,6 +217,7 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("short_run");
expect(prompt).toContain("generate_cover");
expect(prompt).toContain("让用户确认");
expect(prompt).toContain("shortRuntitle、direction");
expect(prompt).not.toContain("short_fiction_run");
expect(prompt).not.toContain("sub_agent");
expect(prompt).not.toContain("architect");
@@ -282,6 +278,8 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("propose_action");
expect(prompt).toContain("script_create");
expect(prompt).toContain("scriptCreate");
expect(prompt).toContain("先用 read 读取");
expect(prompt).toContain("不要要求用户重复上传或粘贴");
expect(prompt).toContain("不要在聊天里直接写完整剧本");
expect(prompt).toContain("不要凭空改写、压缩或替用户补素材");
expect(prompt).not.toContain("script_create");
@@ -310,6 +308,7 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("propose_action");
expect(prompt).toContain("storyboard_create");
expect(prompt).toContain("storyboardCreate");
expect(prompt).toContain("先用 read 读取");
expect(prompt).toContain("不要在聊天里直接写完整分镜");
expect(prompt).toContain("不要凭空改写、压缩或替用户补素材");
expect(prompt).not.toContain("script_create");
@@ -338,6 +337,7 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("propose_action");
expect(prompt).toContain("interactive_film_create");
expect(prompt).toContain("interactiveFilmCreate");
expect(prompt).toContain("先用 read 读取");
expect(prompt).toContain("变量/旗标");
expect(prompt).toContain("多结局");
expect(prompt).toContain("不要在聊天里直接写完整交付稿");
@@ -448,6 +448,8 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("续写新的下一章用 writer");
expect(prompt).toContain("修改、重写或重修已有章节用 reviser");
expect(prompt).toContain("三者不可互换");
expect(prompt).toContain("只重建状态/摘要/伏笔或重新审稿时,用 resync_chapter_state");
expect(prompt).toContain("allowNewHooks=false");
});
it("forbids answering chapter-writing requests with raw chapter prose in chat", () => {
@@ -485,6 +487,35 @@ describe("buildAgentSystemPrompt", () => {
});
});
describe("interactive-film authoring mode", () => {
it("uses the graph-aware authoring harness instead of generic chat", () => {
const prompt = buildAgentSystemPrompt("storm-radio", "zh", "interactive-film-authoring");
expect(prompt).toContain("互动影游创作向导");
expect(prompt).toContain("storm-radio");
expect(prompt).toContain("完整剧情图谱");
expect(prompt).toContain("真实 node id");
expect(prompt).toContain("revise_node");
expect(prompt).toContain("generate_node_image");
expect(prompt).toContain("讨论、比较方案或询问时直接回答,不调用工具");
expect(prompt).toContain("完成态只来自成功工具结果");
expect(prompt).not.toContain("普通聊天助手");
expect(prompt).not.toContain("create_book");
expect(prompt).not.toContain("play_start");
});
it("provides the same execution boundary in English", () => {
const prompt = buildAgentSystemPrompt("storm-radio", "en", "interactive-film-authoring");
expect(prompt).toContain("interactive-film authoring guide");
expect(prompt).toContain("sole authority for node ids");
expect(prompt).toContain("revise_node");
expect(prompt).toContain("generate_node_image");
expect(prompt).toContain("Answer discussion and comparison requests directly without tools");
expect(prompt).not.toContain("general chat assistant");
});
});
describe("global output rules", () => {
it("forbids emoji in Chinese and English prompts", () => {
expect(buildAgentSystemPrompt(null, "zh", "chat")).toContain("不要使用表情符号");
@@ -174,6 +174,7 @@ describe("agent tools language wiring (en parity)", () => {
action: "short_run",
instruction: "Write a complete English suspense short story.",
shortRun: {
title: "The Missing Ledger",
direction: "an office suspense story about forged expense records",
language: "en",
chapters: 12,
@@ -198,6 +199,7 @@ describe("agent tools language wiring (en parity)", () => {
action: "short_run",
instruction: "Write a complete English suspense short story.",
shortRun: {
title: "The Missing Ledger",
direction: "an office suspense story about forged expense records",
chapters: 12,
charsPerChapter: 650,
@@ -215,6 +217,7 @@ describe("agent tools language wiring (en parity)", () => {
action: "short_run",
instruction: "用户在中文对话里要求写一篇英文办公室悬疑短篇",
shortRun: {
title: "The Missing Ledger",
direction: "an English office suspense story about forged expense records",
language: "en",
chapters: 12,
@@ -239,6 +242,7 @@ describe("agent tools language wiring (en parity)", () => {
action: "short_run",
instruction: "用户在中文对话里要求写一篇英文短篇,未指定每章字数",
shortRun: {
title: "The Missing Ledger",
direction: "an English office suspense story",
language: "en",
cover: false,
+203 -9
View File
@@ -11,6 +11,7 @@ import {
createShortFictionRunTool,
createPatchChapterTextTool,
createReplaceChapterTextTool,
createResyncChapterStateTool,
createDeleteLatestChapterTool,
createPlayEditTool,
createPlayStartTool,
@@ -253,6 +254,45 @@ describe("agent deterministic writing tools", () => {
]);
});
it("resyncs derived chapter state and returns the fresh audit result without rewriting prose", async () => {
const pipeline = contextPipeline({
resyncChapterStateAndAudit: vi.fn(async () => ({
chapter: {
chapterNumber: 3,
title: "风暴",
wordCount: 120,
status: "ready-for-review",
},
audit: {
chapterNumber: 3,
passed: false,
summary: "one continuity issue remains",
issues: [{
severity: "warning" as const,
category: "continuity",
description: "The recovered hook is not yet reflected in the final paragraph.",
suggestion: "Align the final paragraph with the persisted hook.",
}],
},
})),
});
const tool = createResyncChapterStateTool(pipeline as never, "harbor", { language: "en" });
const result = await tool.execute("resync-3", { chapterNumber: 3, allowNewHooks: false });
expect(pipeline.resyncChapterStateAndAudit).toHaveBeenCalledWith("harbor", 3, { allowNewHooks: false });
expect(result.details).toMatchObject({
kind: "chapter_state_resynced",
chapterNumber: 3,
auditPassed: false,
status: "audit-failed",
});
expect(result.content[0]).toMatchObject({
type: "text",
text: expect.stringContaining("recovered hook"),
});
});
it("requires an explicit title when the architect sub-agent creates a book", async () => {
const pipeline = {
initBook: vi.fn(async () => undefined),
@@ -294,7 +334,8 @@ describe("agent deterministic writing tools", () => {
expect(en.content[0]?.type).toBe("text");
if (zh.content[0]?.type === "text") {
expect(zh.content[0].text).toContain("创建长篇书籍");
expect(zh.content[0].text).toContain("确认后会切换到对应入口");
expect(zh.content[0].text).toContain("确认后将直接执行");
expect(zh.content[0].text).toContain("不会要求你再去另一个表单重复填写");
}
if (en.content[0]?.type === "text") {
expect(en.content[0].text).toContain("Generate cover");
@@ -308,6 +349,7 @@ describe("agent deterministic writing tools", () => {
const result = await tool.execute("proposal-same-session", {
action: "short_run",
instruction: "写一篇婚姻反杀短篇",
shortRun: { title: "离婚协议", direction: "婚姻反杀短篇" },
});
expect(result.details).toMatchObject({
@@ -327,6 +369,7 @@ describe("agent deterministic writing tools", () => {
const result = await tool.execute("proposal-with-skill", {
action: "short_run",
instruction: "把这份素材蒸馏成一篇商业短篇",
shortRun: { title: "旧账新生", direction: "把已提供素材蒸馏成商业短篇" },
});
expect(result.details).toMatchObject({
@@ -336,6 +379,16 @@ describe("agent deterministic writing tools", () => {
});
});
it("requires a host-owned title and direction before proposing short production", async () => {
const tool = createProposeActionTool("zh");
await expect(tool.execute("proposal-missing-short-title", {
action: "short_run",
instruction: "写一篇婚姻反杀短篇",
shortRun: { direction: "婚姻反杀短篇" } as any,
})).rejects.toThrow(/shortRun\.title/);
});
it("carries structured execution payloads in proposed actions", async () => {
const tool = createProposeActionTool("zh");
@@ -510,6 +563,22 @@ describe("agent deterministic writing tools", () => {
});
});
it("declares executable proposal fields as required in the model-facing schema", () => {
const tool = createProposeActionTool("zh");
const schema = tool.parameters as {
properties?: Record<string, { required?: string[] }>;
};
expect(schema.properties?.interactiveFilmCreate?.required).toContain("title");
expect(schema.properties?.shortRun?.required).toEqual(expect.arrayContaining(["title", "direction"]));
expect(schema.properties?.playStart?.required).toEqual(expect.arrayContaining(["title", "premise", "initialScene"]));
expect(schema.properties?.translationCreate?.required).toEqual(expect.arrayContaining([
"filePath",
"sourceLanguage",
"targetLanguage",
]));
});
it("drops non-positive placeholder counts from interactive-film confirmation payloads", async () => {
const tool = createProposeActionTool("zh");
@@ -551,9 +620,21 @@ describe("agent deterministic writing tools", () => {
suggestedActions: ["检查演出表"],
},
},
runnerFactory: () => ({
runnerFactory: ({ db }) => ({
async seedOpening(input) {
seededScene = input.sceneText;
db.upsertEntity({
id: "actor_player",
type: "actor",
label: "玩家",
summary: "当前玩家。",
});
db.upsertEntity({
id: "location_theater",
type: "location",
label: "旧戏院",
summary: "开场地点。",
});
return null;
},
}),
@@ -597,35 +678,125 @@ describe("agent deterministic writing tools", () => {
})).rejects.toThrow("playStart.title");
});
it("can propose opening existing assisted creation workflows without claiming production", async () => {
it("proposes derivative production with structured payloads and no form route", async () => {
const tool = createProposeActionTool("zh");
const cases = [
{ action: "fanfic_init", route: "import:fanfic", title: "打开同人创作" },
{ action: "spinoff_create", route: "import:spinoff", title: "打开番外创作" },
{ action: "style_imitation", route: "import:imitation", title: "打开仿写/文风分析" },
{
action: "fanfic_init",
payload: { fanficCreate: { title: "霜港来信", sourceText: "原作正典片段", sourceName: "霜港" } },
title: "创建同人作品",
},
{
action: "continuation_import",
payload: { continuationImport: { title: "雾港续章", sourcePath: ".inkos/uploads/novel.txt" } },
title: "导入并续写作品",
},
{
action: "spinoff_create",
payload: { spinoffCreate: { title: "雨夜番外", parentBookId: "harbor", direction: "老船工视角" } },
title: "创建番外作品",
},
{
action: "style_imitation",
payload: { imitationCreate: { title: "纸灯新案", referenceText: "参考文风片段", storyIdea: "原创县城悬疑" } },
title: "创建仿写作品",
},
] as const;
for (const item of cases) {
const result = await tool.execute(`proposal-${item.action}`, {
action: item.action,
instruction: "打开对应 Studio 工具,等待用户补充材料。",
instruction: "确认后直接创建对应作品。",
...item.payload,
});
expect(result.content[0]?.type).toBe("text");
if (result.content[0]?.type === "text") {
expect(result.content[0].text).toContain(item.title);
expect(result.content[0].text).toContain("不会直接生成成品");
expect(result.content[0].text).toContain("确认后将直接执行");
}
expect(result.details).toMatchObject({
kind: "proposed_action",
action: item.action,
targetSessionKind: "chat",
targetRoute: item.route,
actionPayload: item.payload,
});
expect(result.details).not.toHaveProperty("targetRoute");
}
});
it("uses the single host-provided attachment as the derivative source when the model omits its path", async () => {
const attachmentPath = ".inkos/uploads/session/style-source.md";
const tool = createProposeActionTool("zh", {
attachmentPaths: () => [attachmentPath],
});
const result = await tool.execute("proposal-imitation-attachment", {
action: "style_imitation",
instruction: "参考附件文风创作一个全新故事。",
imitationCreate: {
title: "借来的三分钟",
storyIdea: "港口夜班修表师发现全镇的钟每天借走三分钟。",
},
});
expect(result.details).toMatchObject({
kind: "proposed_action",
action: "style_imitation",
actionPayload: {
imitationCreate: {
title: "借来的三分钟",
storyIdea: "港口夜班修表师发现全镇的钟每天借走三分钟。",
referencePath: attachmentPath,
},
},
});
});
it("replaces a truncated uploaded-file path with the single host-provided attachment", async () => {
const attachmentPath = ".inkos/uploads/session/style-source.md";
const tool = createProposeActionTool("zh", {
attachmentPaths: () => [attachmentPath],
});
const result = await tool.execute("proposal-imitation-truncated-attachment", {
action: "style_imitation",
instruction: "参考附件文风创作一个全新故事。",
imitationCreate: {
title: "借来的三分钟",
storyIdea: "港口夜班修表师发现全镇的钟每天借走三分钟。",
referencePath: ".inkos/uploads/1786846...",
},
});
expect(result.details).toMatchObject({
actionPayload: {
imitationCreate: {
referencePath: attachmentPath,
},
},
});
});
it("does not guess among multiple attachment paths", async () => {
const tool = createProposeActionTool("zh", {
attachmentPaths: () => [
".inkos/uploads/session/one.md",
".inkos/uploads/session/two.md",
],
});
await expect(tool.execute("proposal-imitation-ambiguous-attachments", {
action: "style_imitation",
instruction: "参考附件文风创作一个全新故事。",
imitationCreate: {
title: "借来的三分钟",
storyIdea: "港口夜班修表师发现全镇的钟每天借走三分钟。",
},
})).rejects.toThrow(/referenceText or referencePath/);
});
it("passes the explicit architect title straight into initBook", async () => {
const pipeline = contextPipeline({
initBook: vi.fn(async () => undefined),
@@ -1246,6 +1417,29 @@ describe("agent deterministic writing tools", () => {
}
});
it("reads project-local production sources without escaping the project root", async () => {
const filmDir = join(root, "interactive-films", "storm-eye");
await mkdir(filmDir, { recursive: true });
await writeFile(join(filmDir, "script.md"), "# Storm Eye\n\nAuthoritative source.", "utf-8");
const tool = createReadTool(root, { scope: "project" });
const result = await tool.execute("tool-read-project", {
path: "interactive-films/storm-eye/script.md",
});
expect(result.content[0]).toEqual({
type: "text",
text: "# Storm Eye\n\nAuthoritative source.",
});
const escaped = await tool.execute("tool-read-project-escape", {
path: "../outside.md",
});
expect(escaped.content[0]?.type).toBe("text");
if (escaped.content[0]?.type === "text") {
expect(escaped.content[0].text).toContain("Path traversal blocked");
}
});
it("reads absolute system paths when explicitly enabled", async () => {
const outsidePath = join(root, "outside.md");
await writeFile(outsidePath, "outside secret", "utf-8");
@@ -2,7 +2,12 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { createBookContextTransform } from "../agent/context-transform.js";
import {
createBookContextTransform,
createInteractiveFilmContextTransform,
} from "../agent/context-transform.js";
import { saveStoryGraph } from "../interactive-film/graph-store.js";
import { StoryGraphSchema } from "../interactive-film/graph-schema.js";
describe("createBookContextTransform", () => {
let projectRoot: string;
@@ -179,3 +184,62 @@ describe("createBookContextTransform", () => {
expect(injected.content).toContain("暴雨夜发现电表账单异常。");
});
});
describe("createInteractiveFilmContextTransform", () => {
let projectRoot: string;
beforeEach(async () => {
projectRoot = await mkdtemp(join(tmpdir(), "film-ctx-test-"));
});
afterEach(async () => {
await rm(projectRoot, { recursive: true, force: true });
});
it("injects the complete authoritative graph and refreshes it from disk every turn", async () => {
const base = StoryGraphSchema.parse({
schemaVersion: 1,
projectId: "storm-radio",
title: "风眼旧频率",
variables: [{ name: "团伙已警觉", type: "flag", default: false }],
nodes: [
{
id: "node_1",
type: "branch",
title: "公开呼叫",
choices: [{ id: "choice_signal", text: "用暗号试探", targetNodeId: "node_6" }],
},
{ id: "node_6", type: "ending", title: "风眼之外", choices: [] },
],
endings: [{ id: "ending_c", nodeId: "node_6", title: "风眼之外", type: "secret" }],
});
await saveStoryGraph(projectRoot, "storm-radio", base);
const transform = createInteractiveFilmContextTransform("storm-radio", projectRoot);
const original = [{ role: "user" as const, content: "讨论节点1", timestamp: Date.now() }];
const first = await transform(original);
const firstContext = (first[0] as { content: string }).content;
expect(firstContext).toContain("完整权威剧情图谱");
expect(firstContext).toContain('"id":"node_1"');
expect(firstContext).toContain('"targetNodeId":"node_6"');
expect(firstContext).toContain('"name":"团伙已警觉"');
expect(firstContext).not.toContain("旧的条目式格式");
expect(first[1]).toBe(original[0]);
await saveStoryGraph(projectRoot, "storm-radio", StoryGraphSchema.parse({
...base,
nodes: base.nodes.map((node) => node.id === "node_1"
? { ...node, title: "暗号试探" }
: node),
}));
const second = await transform(original);
expect((second[0] as { content: string }).content).toContain('"title":"暗号试探"');
});
it("leaves messages unchanged before a graph has been created", async () => {
const transform = createInteractiveFilmContextTransform("missing-film", projectRoot);
const original = [{ role: "user" as const, content: "hello", timestamp: Date.now() }];
expect(await transform(original)).toBe(original);
});
});
@@ -1,5 +1,8 @@
import { describe, expect, it, vi } from "vitest";
import { FoundationReviewerAgent } from "../agents/foundation-reviewer.js";
import {
FoundationReviewerAgent,
FoundationReviewParseError,
} from "../agents/foundation-reviewer.js";
import type { LLMClient } from "../llm/provider.js";
const TEST_CLIENT: LLMClient = {
@@ -129,4 +132,40 @@ describe("FoundationReviewerAgent", () => {
expect(messages[1]?.content).toContain("CURRENT_STATE_TAIL_MARKER");
expect(messages[1]?.content).toContain("PENDING_HOOKS_TAIL_MARKER");
});
it("does not turn a malformed review into fake 50-point quality scores", async () => {
const agent = new FoundationReviewerAgent({
client: TEST_CLIENT,
model: "test-model",
projectRoot: process.cwd(),
});
vi.spyOn(
agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> },
"chat",
).mockResolvedValue({
content: [
"### 核心冲突",
"分数:82",
"意见:主线清楚,但模型没有遵守约定的分项边界。",
].join("\n"),
usage: ZERO_USAGE,
});
await expect(agent.review({
language: "zh",
mode: "original",
targetChapters: 60,
foundation: {
storyBible: "故事框架",
volumeOutline: "60章大纲",
bookRules: "规则",
currentState: "状态",
pendingHooks: "伏笔",
},
})).rejects.toEqual(expect.objectContaining<Partial<FoundationReviewParseError>>({
name: "FoundationReviewParseError",
missingDimensions: [1, 2, 3, 4, 5],
}));
});
});
@@ -32,7 +32,7 @@ function createDelta(overrides: Partial<RuntimeStateDelta> = {}): RuntimeStateDe
}
describe("arbitrateRuntimeStateDeltaHooks", () => {
it("maps a duplicate-family candidate back onto the matched existing hook", () => {
it("updates an existing hook only when the settler names its canonical id", () => {
const result = arbitrateRuntimeStateDeltaHooks({
hooks: [
createHook({
@@ -45,13 +45,20 @@ describe("arbitrateRuntimeStateDeltaHooks", () => {
}),
],
delta: createDelta({
newHookCandidates: [
{
hookOps: {
upsert: [createHook({
hookId: "anonymous-source-scope",
type: "source-risk",
startChapter: 3,
lastAdvancedChapter: 12,
status: "progressing",
expectedPayoff: "Reveal how much the anonymous source already knew about the route and address.",
notes: "This chapter adds the address angle to the anonymous source question.",
},
],
})],
mention: [],
resolve: [],
defer: [],
},
}),
});
@@ -64,7 +71,7 @@ describe("arbitrateRuntimeStateDeltaHooks", () => {
expect(result.resolvedDelta.newHookCandidates).toEqual([]);
});
it("downgrades a pure restatement candidate into a mention instead of opening a new hook", () => {
it("does not infer semantic identity for an unnamed candidate", () => {
const result = arbitrateRuntimeStateDeltaHooks({
hooks: [
createHook({
@@ -85,9 +92,12 @@ describe("arbitrateRuntimeStateDeltaHooks", () => {
}),
});
expect(result.resolvedDelta.hookOps.upsert).toEqual([]);
expect(result.resolvedDelta.hookOps.mention).toContain("mentor-debt");
expect(result.resolvedDelta.hookOps.upsert).toHaveLength(1);
expect(result.resolvedDelta.hookOps.upsert[0]?.hookId).not.toBe("mentor-debt");
expect(result.resolvedDelta.newHookCandidates).toEqual([]);
expect(result.decisions).toEqual([
expect.objectContaining({ action: "created", reason: "admit" }),
]);
});
it("creates a canonical hook when the candidate is genuinely new", () => {
@@ -121,4 +131,49 @@ describe("arbitrateRuntimeStateDeltaHooks", () => {
expect(result.resolvedDelta.hookOps.upsert[0]?.hookId).not.toBe("mentor-debt");
expect(result.resolvedDelta.newHookCandidates).toEqual([]);
});
it("can structurally forbid hook-set expansion without guessing semantic identity", () => {
const result = arbitrateRuntimeStateDeltaHooks({
hooks: [createHook({ hookId: "H012" })],
allowNewHooks: false,
delta: createDelta({
hookOps: {
upsert: [createHook({ hookId: "H012", status: "progressing", lastAdvancedChapter: 12 })],
mention: [],
resolve: [],
defer: [],
},
newHookCandidates: [{
type: "mystery",
expectedPayoff: "Explain why the clock moved eleven minutes.",
notes: "The same chapter-ending question as H012.",
}],
}),
});
expect(result.resolvedDelta.hookOps.upsert).toEqual([
expect.objectContaining({ hookId: "H012", status: "progressing" }),
]);
expect(result.decisions).toEqual([
expect.objectContaining({ action: "rejected", reason: "new_hooks_disabled" }),
]);
});
it("rejects structurally incomplete candidates without inventing content", () => {
const result = arbitrateRuntimeStateDeltaHooks({
hooks: [],
delta: createDelta({
newHookCandidates: [{
type: "mystery",
expectedPayoff: "",
notes: "",
}],
}),
});
expect(result.resolvedDelta.hookOps.upsert).toEqual([]);
expect(result.decisions).toEqual([
expect.objectContaining({ action: "rejected", reason: "missing_payoff_signal" }),
]);
});
});
@@ -53,15 +53,6 @@ describe("collectStaleHookDebt", () => {
});
describe("evaluateHookAdmission", () => {
const activeHooks = [
createHook({
hookId: "H019",
type: "mystery",
expectedPayoff: "Reveal the hidden room behind the correction mark",
notes: "The hidden room converts public disputes into standing questions",
}),
];
it("rejects hook candidates without payoff-bearing signal", () => {
const decision = evaluateHookAdmission({
candidate: {
@@ -69,7 +60,6 @@ describe("evaluateHookAdmission", () => {
expectedPayoff: "",
notes: " ",
},
activeHooks,
});
expect(decision).toEqual({
@@ -85,7 +75,6 @@ describe("evaluateHookAdmission", () => {
expectedPayoff: "Reveal why the witness changed her statement",
notes: "A courtroom contradiction keeps widening",
},
activeHooks,
});
expect(decision).toEqual({
@@ -94,20 +83,18 @@ describe("evaluateHookAdmission", () => {
});
});
it("rejects duplicate or restated hook candidates", () => {
it("only validates structure and leaves semantic identity to the settler", () => {
const decision = evaluateHookAdmission({
candidate: {
type: "mystery",
expectedPayoff: "Reveal the hidden room behind the correction mark",
notes: "The hidden room still reframes public disputes as standing questions",
},
activeHooks,
});
expect(decision).toEqual({
admit: false,
reason: "duplicate_family",
matchedHookId: "H019",
admit: true,
reason: "admit",
});
});
@@ -118,7 +105,6 @@ describe("evaluateHookAdmission", () => {
expectedPayoff: "Expose why the mentor buried the oath",
notes: "A separate emotional debt keeps surfacing in private scenes",
},
activeHooks,
});
expect(decision).toEqual({
@@ -127,27 +113,18 @@ describe("evaluateHookAdmission", () => {
});
});
it("rejects Chinese paraphrase candidates from the same hook family", () => {
it("does not guess Chinese semantic equivalence with token overlap", () => {
const decision = evaluateHookAdmission({
candidate: {
type: "神秘",
expectedPayoff: "弄明白雨夜匿名来电背后是谁",
notes: "一下雨就有陌生号码劝她远离旧码头",
},
activeHooks: [
createHook({
hookId: "H020",
type: "神秘",
expectedPayoff: "查出匿名号码为何总在雨夜响起",
notes: "每次雨夜都有人用匿名电话提醒她别去旧码头",
}),
],
});
expect(decision).toEqual({
admit: false,
reason: "duplicate_family",
matchedHookId: "H020",
admit: true,
reason: "admit",
});
});
});
@@ -1,23 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildFillNodeDeltaFromLLMText, buildStructureDeltaFromLLMText } from "../interactive-film/authoring-generate.js";
const nodeJson = JSON.stringify({ id: "WILL_OVERRIDE", type: "branch", title: "抉择", sceneDesc: "宫门前", dialogue: [{ speaker: "阿梅", text: "账不能错", emotion: "坚定" }], choices: [{ id: "a", text: "公开", targetNodeId: "n2" }] });
const structJson = JSON.stringify({ nodes: [
{ id: "s", type: "start", choices: [{ id: "c", text: "go", targetNodeId: "e" }] },
{ id: "e", type: "ending", choices: [] },
] });
describe("authoring-generate builders", () => {
it("fill_node: parses a node and forces its id", () => {
const d = buildFillNodeDeltaFromLLMText("```json\n" + nodeJson + "\n```", "real-node");
expect(d.nodes?.upsert?.[0].id).toBe("real-node");
expect(d.nodes?.upsert?.[0].dialogue?.[0].speaker).toBe("阿梅");
});
it("draft_structure: parses a nodes array", () => {
const d = buildStructureDeltaFromLLMText(structJson);
expect(d.nodes?.upsert?.map(n => n.id)).toEqual(["s", "e"]);
});
it("draft_structure: throws on empty nodes", () => {
expect(() => buildStructureDeltaFromLLMText(JSON.stringify({ nodes: [] }))).toThrow();
});
});
@@ -2,12 +2,27 @@ import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createFillNodeTool, createReviseNodeTool } from "../agent/film-authoring-tools.js";
import { createFillNodeTool, createReviseNodeTool, type FilmLLMDeps } from "../agent/film-authoring-tools.js";
import { loadStoryGraph } from "../interactive-film/graph-store.js";
import { saveStoryGraph } from "../interactive-film/graph-store.js";
import { StoryGraphSchema } from "../interactive-film/graph-schema.js";
import { StoryGraphSchema, StoryNodeSchema } from "../interactive-film/graph-schema.js";
const node = JSON.stringify({ type: "branch", title: "抉择", sceneDesc: "宫门前", dialogue: [{ speaker: "阿梅", text: "账不能错", emotion: "坚定" }], choices: [{ id: "a", text: "公开", targetNodeId: "e" }] });
const node = StoryNodeSchema.parse({
id: "n1",
type: "branch",
title: "抉择",
sceneDesc: "宫门前",
dialogue: [{ speaker: "阿梅", text: "账不能错", emotion: "坚定" }],
choices: [{ id: "a", text: "公开", targetNodeId: "e" }],
});
function filmDeps(overrides: Partial<FilmLLMDeps> = {}): FilmLLMDeps {
return {
submitNode: async (_system, _user, nodeId) => ({ ...node, id: nodeId }),
submitStructure: async () => [],
...overrides,
};
}
describe("fill_node tool (stubbed LLM)", () => {
let root: string;
@@ -19,10 +34,9 @@ describe("fill_node tool (stubbed LLM)", () => {
afterEach(async () => { await rm(root, { recursive: true, force: true }); });
it("fills a node from stubbed LLM text and persists it", async () => {
const tool = createFillNodeTool(root, "p", {
chat: async () => "```json\n" + node + "\n```",
const tool = createFillNodeTool(root, "p", filmDeps({
skillIds: () => ["inkos-interactive-film"],
});
}));
const result = await tool.execute("call-1", { nodeId: "n1", instruction: "写抉择场景" } as never);
const g = await loadStoryGraph(root, "p");
expect(g?.nodes.find(n => n.id === "n1")?.dialogue?.[0].speaker).toBe("阿梅");
@@ -33,12 +47,12 @@ describe("fill_node tool (stubbed LLM)", () => {
await mkdir(join(root, "prompt", "interactive-film"), { recursive: true });
await writeFile(join(root, "prompt", "interactive-film", "script.md"), "PROJECT SCRIPT OVERRIDE: keep node dialogue short and playable.");
let systemPrompt = "";
const tool = createFillNodeTool(root, "p", {
chat: async (system) => {
const tool = createFillNodeTool(root, "p", filmDeps({
submitNode: async (system, _user, nodeId) => {
systemPrompt = system;
return "```json\n" + node + "\n```";
return { ...node, id: nodeId };
},
});
}));
const result = await tool.execute("call-1", { nodeId: "n1", instruction: "写抉择场景" } as never);
@@ -66,8 +80,17 @@ describe("revise_node tool (stubbed LLM)", () => {
afterEach(async () => { await rm(root, { recursive: true, force: true }); });
it("revises a node via stubbed LLM text and persists updated dialogue", async () => {
const revised = JSON.stringify({ type: "branch", title: "修改后", sceneDesc: "新场景", dialogue: [{ speaker: "新人", text: "新台词", emotion: "激动" }], choices: [{ id: "c1", text: "继续", targetNodeId: "e" }] });
const tool = createReviseNodeTool(root, "p", { chat: async () => "```json\n" + revised + "\n```" });
const revised = StoryNodeSchema.parse({
id: "n1",
type: "branch",
title: "修改后",
sceneDesc: "新场景",
dialogue: [{ speaker: "新人", text: "新台词", emotion: "激动" }],
choices: [{ id: "c1", text: "继续", targetNodeId: "e" }],
});
const tool = createReviseNodeTool(root, "p", filmDeps({
submitNode: async (_system, _user, nodeId) => ({ ...revised, id: nodeId }),
}));
await tool.execute("call-2", { nodeId: "n1", instruction: "改写" } as never);
const g = await loadStoryGraph(root, "p");
const updated = g?.nodes.find(n => n.id === "n1");
@@ -2,14 +2,27 @@ import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createDraftStructureTool, createRemoveNodeTool, createConnectChoiceTool } from "../agent/film-authoring-tools.js";
import {
createDraftStructureTool,
createRemoveNodeTool,
createConnectChoiceTool,
type FilmLLMDeps,
} from "../agent/film-authoring-tools.js";
import { loadStoryGraph, saveStoryGraph } from "../interactive-film/graph-store.js";
import { StoryGraphSchema } from "../interactive-film/graph-schema.js";
const structure = JSON.stringify({ nodes: [
const structure = StoryGraphSchema.shape.nodes.parse([
{ id: "s", type: "start", choices: [{ id: "c", text: "go", targetNodeId: "e" }] },
{ id: "e", type: "ending", choices: [] },
] });
]);
function filmDeps(overrides: Partial<FilmLLMDeps> = {}): FilmLLMDeps {
return {
submitNode: async () => structure[0]!,
submitStructure: async () => structure,
...overrides,
};
}
describe("confirm-class authoring tools", () => {
let root: string;
@@ -17,7 +30,7 @@ describe("confirm-class authoring tools", () => {
afterEach(async () => { await rm(root, { recursive: true, force: true }); });
it("draft_structure (stubbed LLM) creates the node skeleton", async () => {
const tool = createDraftStructureTool(root, "p", { chat: async () => structure });
const tool = createDraftStructureTool(root, "p", filmDeps());
await tool.execute("call-1", { instruction: "三幕" } as never);
expect((await loadStoryGraph(root, "p"))?.nodes.map(n => n.id).sort()).toEqual(["e", "s"]);
});
@@ -26,12 +39,12 @@ describe("confirm-class authoring tools", () => {
await mkdir(join(root, "prompt", "interactive-film"), { recursive: true });
await writeFile(join(root, "prompt", "interactive-film", "story-graph.md"), "PROJECT STORY GRAPH OVERRIDE: every branch needs a visible flag.");
let systemPrompt = "";
const tool = createDraftStructureTool(root, "p", {
chat: async (system) => {
const tool = createDraftStructureTool(root, "p", filmDeps({
submitStructure: async (system) => {
systemPrompt = system;
return structure;
},
});
}));
const result = await tool.execute("call-1", { instruction: "三幕" } as never);
@@ -1,76 +1,34 @@
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import { describe, expect, it, beforeEach, afterEach } from "vitest";
import { mkdtemp, rm, mkdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import * as llmProvider from "../llm/provider.js";
import type { LLMClient } from "../llm/provider.js";
import { generateStoryGraph } from "../interactive-film/generate.js";
import {
createFillNodeTool,
createReviseNodeTool,
createDraftStructureTool,
type FilmLLMDeps,
} from "../agent/film-authoring-tools.js";
import { saveStoryGraph } from "../interactive-film/graph-store.js";
import { StoryGraphSchema } from "../interactive-film/graph-schema.js";
import { StoryGraphSchema, StoryNodeSchema } from "../interactive-film/graph-schema.js";
const STUB_CLIENT: LLMClient = {
provider: "openai",
apiFormat: "chat",
stream: false,
defaults: { temperature: 0.7, maxTokens: 2048, thinkingBudget: 0, maxTokensCap: null, extra: {} },
} as LLMClient;
const validGraphJson = JSON.stringify({
schemaVersion: 1, projectId: "x", title: "G", variables: [],
nodes: [
{ id: "s", type: "start", choices: [{ id: "c", text: "go", targetNodeId: "e" }] },
{ id: "e", type: "ending", choices: [] },
],
endings: [{ id: "x", nodeId: "e", title: "end", type: "good" }],
});
const nodeJson = JSON.stringify({
type: "branch", title: "Choice", sceneDesc: "At the gate",
const node = StoryNodeSchema.parse({
id: "n1", type: "branch", title: "Choice", sceneDesc: "At the gate",
dialogue: [{ speaker: "Mei", text: "The ledger cannot lie", emotion: "resolute" }],
choices: [{ id: "a", text: "Go public", targetNodeId: "e" }],
});
const structureJson = JSON.stringify({ nodes: [
const structureNodes = StoryGraphSchema.shape.nodes.parse([
{ id: "s", type: "start", choices: [{ id: "c", text: "go", targetNodeId: "e" }] },
{ id: "e", type: "ending", choices: [] },
] });
]);
describe("generateStoryGraph language switch", () => {
afterEach(() => { vi.restoreAllMocks(); });
it("uses the English system prompt and English user prompt when language is en", async () => {
const chatSpy = vi.spyOn(llmProvider, "chatCompletion").mockResolvedValue({
content: "```json\n" + validGraphJson + "\n```",
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
} as unknown as Awaited<ReturnType<typeof llmProvider.chatCompletion>>);
await generateStoryGraph(STUB_CLIENT, "m", { projectId: "p", title: "T", premise: "A heist" }, { language: "en" });
const messages = chatSpy.mock.calls[0][2];
expect(messages[0].content).toContain("You are an interactive film scriptwriter");
expect(messages[0].content).not.toContain("你是互动影游编剧");
expect(messages[1].content).toContain("Title: T");
expect(messages[1].content).toContain("Premise: A heist");
});
it("defaults to the Chinese system prompt when language is omitted", async () => {
const chatSpy = vi.spyOn(llmProvider, "chatCompletion").mockResolvedValue({
content: "```json\n" + validGraphJson + "\n```",
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0 },
} as unknown as Awaited<ReturnType<typeof llmProvider.chatCompletion>>);
await generateStoryGraph(STUB_CLIENT, "m", { projectId: "p", title: "标题T", premise: "前提P" });
const messages = chatSpy.mock.calls[0][2];
expect(messages[0].content).toContain("你是互动影游编剧");
expect(messages[1].content).toContain("标题:标题T");
});
});
function filmDeps(overrides: Partial<FilmLLMDeps> = {}): FilmLLMDeps {
return {
submitNode: async (_system, _user, nodeId) => ({ ...node, id: nodeId }),
submitStructure: async () => structureNodes,
...overrides,
};
}
describe("film authoring LLM tools language switch", () => {
let root: string;
@@ -88,13 +46,13 @@ describe("film authoring LLM tools language switch", () => {
it("fill_node with language en sends the English node system prompt and user prompt", async () => {
let systemPrompt = "";
let userPrompt = "";
const tool = createFillNodeTool(root, "p", {
chat: async (system, user) => {
const tool = createFillNodeTool(root, "p", filmDeps({
submitNode: async (system, user, nodeId) => {
systemPrompt = system;
userPrompt = user;
return "```json\n" + nodeJson + "\n```";
return { ...node, id: nodeId };
},
}, "en");
}), "en");
await tool.execute("call-1", { nodeId: "n1", instruction: "Write the decision scene" } as never);
@@ -107,13 +65,13 @@ describe("film authoring LLM tools language switch", () => {
it("fill_node defaults to the Chinese system prompt when language is omitted", async () => {
let systemPrompt = "";
let userPrompt = "";
const tool = createFillNodeTool(root, "p", {
chat: async (system, user) => {
const tool = createFillNodeTool(root, "p", filmDeps({
submitNode: async (system, user, nodeId) => {
systemPrompt = system;
userPrompt = user;
return "```json\n" + nodeJson + "\n```";
return { ...node, id: nodeId };
},
});
}));
await tool.execute("call-2", { nodeId: "n1", instruction: "写抉择场景" } as never);
@@ -124,13 +82,13 @@ describe("film authoring LLM tools language switch", () => {
it("revise_node with language en sends the English node system prompt and user prompt", async () => {
let systemPrompt = "";
let userPrompt = "";
const tool = createReviseNodeTool(root, "p", {
chat: async (system, user) => {
const tool = createReviseNodeTool(root, "p", filmDeps({
submitNode: async (system, user, nodeId) => {
systemPrompt = system;
userPrompt = user;
return "```json\n" + nodeJson + "\n```";
return { ...node, id: nodeId };
},
}, "en");
}), "en");
await tool.execute("call-3", { nodeId: "n1", instruction: "Tighten the dialogue" } as never);
@@ -142,13 +100,13 @@ describe("film authoring LLM tools language switch", () => {
it("draft_structure with language en sends the English structure system prompt and user prompt", async () => {
let systemPrompt = "";
let userPrompt = "";
const tool = createDraftStructureTool(root, "p", {
chat: async (system, user) => {
const tool = createDraftStructureTool(root, "p", filmDeps({
submitStructure: async (system, user) => {
systemPrompt = system;
userPrompt = user;
return structureJson;
return structureNodes;
},
}, "en");
}), "en");
await tool.execute("call-4", { instruction: "Three acts" } as never);
@@ -161,13 +119,13 @@ describe("film authoring LLM tools language switch", () => {
it("draft_structure defaults to the Chinese structure system prompt when language is omitted", async () => {
let systemPrompt = "";
let userPrompt = "";
const tool = createDraftStructureTool(root, "p", {
chat: async (system, user) => {
const tool = createDraftStructureTool(root, "p", filmDeps({
submitStructure: async (system, user) => {
systemPrompt = system;
userPrompt = user;
return structureJson;
return structureNodes;
},
});
}));
await tool.execute("call-5", { instruction: "三幕" } as never);
+85 -31
View File
@@ -1,37 +1,91 @@
import { describe, expect, it } from "vitest";
import { extractJson, buildStoryGraphFromLLMText } from "../interactive-film/generate.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { LLMClient } from "../llm/provider.js";
import { generateStoryGraph } from "../interactive-film/generate.js";
const validGraphJson = JSON.stringify({
schemaVersion: 1, projectId: "WILL_BE_OVERRIDDEN", title: "G", variables: [],
nodes: [
{ id: "s", type: "start", choices: [{ id: "c", text: "go", targetNodeId: "e" }] },
{ id: "e", type: "ending", choices: [] },
],
endings: [{ id: "x", nodeId: "e", title: "end", type: "good" }],
});
const runWorkerAgentToolMock = vi.hoisted(() => vi.fn());
describe("extractJson", () => {
it("extracts from a fenced ```json block", () => {
const obj = extractJson("前言\n```json\n{\"a\":1}\n```\n后语") as { a: number };
expect(obj.a).toBe(1);
vi.mock("../agent/worker-agent.js", () => ({
runWorkerAgentTool: runWorkerAgentToolMock,
}));
const client = {} as LLMClient;
function playableGraphContent() {
return {
projectId: "model-must-not-own-this",
title: "model-must-not-own-this",
variables: [{ name: "trust", type: "relationship", default: 0, desc: "Trust" }],
nodes: [
{ id: "s", type: "start", choices: [{ id: "s-b1", text: "enter", targetNodeId: "b1" }] },
{
id: "b1",
type: "branch",
choices: [
{ id: "b1-b2", text: "investigate", targetNodeId: "b2", effects: [{ var: "trust", op: "add", value: 1 }] },
{ id: "b1-e1", text: "leave", targetNodeId: "e1" },
],
},
{
id: "b2",
type: "branch",
choices: [
{ id: "b2-e1", text: "trust", targetNodeId: "e1" },
{ id: "b2-e2", text: "expose", targetNodeId: "e2" },
],
},
{ id: "e1", type: "ending", choices: [] },
{ id: "e2", type: "ending", choices: [] },
],
endings: [
{ id: "ending-1", nodeId: "e1", title: "Trust", type: "good" },
{ id: "ending-2", nodeId: "e2", title: "Exposure", type: "secret" },
],
};
}
describe("generateStoryGraph structured worker", () => {
beforeEach(() => {
runWorkerAgentToolMock.mockReset();
runWorkerAgentToolMock.mockResolvedValue(playableGraphContent());
});
it("extracts a bare JSON object", () => {
const obj = extractJson("noise {\"a\":2} tail") as { a: number };
expect(obj.a).toBe(2);
it("uses a typed Pi result tool and keeps host-owned identity authoritative", async () => {
const graph = await generateStoryGraph(client, "m", {
projectId: "real-id",
title: "Real title",
premise: "A branching mystery",
}, { language: "en" });
expect(graph.projectId).toBe("real-id");
expect(graph.title).toBe("Real title");
expect(graph.nodes).toHaveLength(5);
expect(runWorkerAgentToolMock).toHaveBeenCalledTimes(1);
const [, , messages, tool] = runWorkerAgentToolMock.mock.calls[0];
expect(messages[0].content).toContain("interactive film scriptwriter");
expect(messages[0].content).not.toContain("Output strictly JSON");
expect(tool.name).toBe("submit_story_graph");
expect(tool.parameters.type).toBe("object");
});
it("throws when no JSON object is present", () => {
expect(() => extractJson("no json here")).toThrow();
});
});
describe("buildStoryGraphFromLLMText", () => {
it("parses and forces projectId from the argument", () => {
const g = buildStoryGraphFromLLMText("```json\n" + validGraphJson + "\n```", "real-id");
expect(g.projectId).toBe("real-id");
expect(g.nodes).toHaveLength(2);
});
it("throws on schema-invalid graph", () => {
const bad = JSON.stringify({ schemaVersion: 1, title: "x" }); // missing projectId/nodes shape
expect(() => buildStoryGraphFromLLMText(bad, "real-id")).toThrow();
it("rejects a structurally valid but unplayable graph instead of writing a generic fallback", async () => {
runWorkerAgentToolMock.mockResolvedValue({
nodes: [
{ id: "s", type: "start", choices: [] },
{ id: "b1", type: "branch", choices: [] },
{ id: "b2", type: "branch", choices: [] },
{ id: "e1", type: "ending", choices: [] },
{ id: "e2", type: "ending", choices: [] },
],
endings: [
{ id: "one", nodeId: "e1", title: "One", type: "good" },
{ id: "two", nodeId: "e2", title: "Two", type: "bad" },
],
});
await expect(generateStoryGraph(client, "m", {
projectId: "p",
title: "T",
premise: "P",
})).rejects.toThrow("Generated story graph is not playable");
});
});
@@ -8,11 +8,18 @@ import { StoryGraphSchema } from "../interactive-film/graph-schema.js";
import type { NodeImageDeps } from "../interactive-film/node-image.js";
const PNG = Buffer.from([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]);
const stub: NodeImageDeps = { generateImage: async () => ({ buffer: PNG, extension: "png" }) };
let generatedSize = "";
const stub: NodeImageDeps = {
generateImage: async (_prompt, size) => {
generatedSize = size;
return { buffer: PNG, extension: "png" };
},
};
describe("generate_node_image tool", () => {
let root: string;
beforeEach(async () => {
generatedSize = "";
root = await mkdtemp(join(tmpdir(), "if-imgtool-"));
await saveStoryGraph(root, "p", StoryGraphSchema.parse({ schemaVersion: 1, projectId: "p", title: "T", variables: [], nodes: [{ id: "s", type: "start", sceneDesc: "宫门前", choices: [] }], endings: [] }));
});
@@ -23,6 +30,13 @@ describe("generate_node_image tool", () => {
await tool.execute("call-1", { nodeId: "s" } as never);
const g = await loadStoryGraph(root, "p");
expect(g?.nodes.find(n => n.id === "s")?.imageSlot?.assetRef).toBe("interactive-films/p/assets/nodes/s.png");
expect(generatedSize).toBe("1536x1024");
});
it("passes an explicit portrait or square size through to the image provider", async () => {
const tool = createGenerateNodeImageTool(root, "p", stub);
await tool.execute("call-sized", { nodeId: "s", size: "1024x1536" } as never);
expect(generatedSize).toBe("1024x1536");
});
it("throws a clear error when the node id does not exist in the graph", async () => {
@@ -10,6 +10,10 @@ import {
PlayModeSchema,
RequestedIntentSchema,
InteractiveFilmCreateActionPayloadSchema,
FanficCreateActionPayloadSchema,
ContinuationImportActionPayloadSchema,
SpinoffCreateActionPayloadSchema,
ImitationCreateActionPayloadSchema,
ScriptCreateActionPayloadSchema,
ScriptTargetFormatSchema,
SessionKindSchema,
@@ -113,6 +117,40 @@ describe("interaction models", () => {
});
});
it("validates derivative-work payloads without magic routes", () => {
expect(FanficCreateActionPayloadSchema.parse({
title: "霜港来信",
sourcePath: ".inkos/uploads/canon.pdf",
mode: "canon",
})).toMatchObject({ title: "霜港来信", mode: "canon" });
expect(FanficCreateActionPayloadSchema.safeParse({ title: "缺少正典" }).success).toBe(false);
expect(ContinuationImportActionPayloadSchema.parse({
title: "雾港续章",
sourcePath: ".inkos/uploads/novel.txt",
})).toMatchObject({ title: "雾港续章" });
expect(ContinuationImportActionPayloadSchema.safeParse({
sourcePath: "novel.txt",
targetRoute: "import:continuation",
}).success).toBe(false);
expect(SpinoffCreateActionPayloadSchema.parse({
title: "雨夜番外",
parentBookId: "harbor",
direction: "老船工视角",
})).toMatchObject({ parentBookId: "harbor" });
expect(ImitationCreateActionPayloadSchema.parse({
title: "纸灯新案",
referenceText: "参考文风片段",
storyIdea: "原创县城悬疑",
})).toMatchObject({ storyIdea: "原创县城悬疑" });
expect(ImitationCreateActionPayloadSchema.safeParse({
title: "缺少参考",
storyIdea: "原创故事",
}).success).toBe(false);
});
it("recognizes terminal execution statuses", () => {
expect(isTerminalExecutionStatus(ExecutionStatusSchema.parse("completed"))).toBe(true);
expect(isTerminalExecutionStatus(ExecutionStatusSchema.parse("failed"))).toBe(true);
@@ -101,6 +101,43 @@ describe("retrieveMemorySelection", () => {
expect(result.hooks.map((hook) => hook.hookId)).not.toContain("H-seed");
});
it("retrieves a relevant deferred seed without promoting it to active debt", async () => {
root = await mkdtemp(join(tmpdir(), "inkos-memory-retrieval-deferred-seed-"));
const bookDir = join(root, "book");
const storyDir = join(bookDir, "story");
await mkdir(storyDir, { recursive: true });
await Promise.all([
writeFile(join(storyDir, "current_state.md"), "# 当前状态\n", "utf-8"),
writeFile(join(storyDir, "chapter_summaries.md"), "# 章节摘要\n", "utf-8"),
writeFile(
join(storyDir, "pending_hooks.md"),
[
"| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 回收节奏 | depends_on | 回收位置 | 核心 | 半衰期 | 升级 | 备注 |",
"| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |",
"| H012 | 0 | 单元案(座钟伪证) | deferred | 0 | 第一卷回收 | 立即 | 无 | 第一卷 | 否 | 10 | 否 | 孙玉珍家座钟被回拨,关联高永强不在场证明 |",
"| H099 | 0 | 远期人物线 | deferred | 0 | 第五卷回收 | 慢烧 | 无 | 第五卷 | 否 | 30 | 否 | 远期亲属关系秘密 |",
"",
].join("\n"),
"utf-8",
),
]);
const result = await retrieveMemorySelection({
bookDir,
chapterNumber: 1,
goal: "写孙玉珍抱座钟进店,发现座钟被回拨并牵出不在场证明。",
});
expect(result.activeHooks).toEqual([]);
expect(result.recyclableHooks).toEqual([]);
expect(result.hooks.map((hook) => hook.hookId)).toContain("H012");
expect(result.hooks.map((hook) => hook.hookId)).not.toContain("H099");
expect(result.retrievalTrace.candidates).toEqual(expect.arrayContaining([
expect.objectContaining({ id: "hook:H012" }),
]));
});
it("prefers the mentor-debt recap chapter over nearby guild-noise chapters in English retrieval", async () => {
root = await mkdtemp(join(tmpdir(), "inkos-memory-retrieval-en-test-"));
const bookDir = join(root, "book");
@@ -9,13 +9,16 @@ import { StateManager } from "../state/manager.js";
import { ArchitectAgent } from "../agents/architect.js";
import { PlannerAgent } from "../agents/planner.js";
import * as ComposerModule from "../agents/composer.js";
import { WriterAgent, type WriteChapterOutput } from "../agents/writer.js";
import { WriterAgent, type SettleChapterStateInput, type WriteChapterOutput } from "../agents/writer.js";
import { LengthNormalizerAgent } from "../agents/length-normalizer.js";
import { ContinuityAuditor, type AuditIssue, type AuditResult } from "../agents/continuity.js";
import { ReviserAgent, type ReviseOutput } from "../agents/reviser.js";
import { ChapterAnalyzerAgent } from "../agents/chapter-analyzer.js";
import { StateValidatorAgent } from "../agents/state-validator.js";
import { FoundationReviewerAgent } from "../agents/foundation-reviewer.js";
import {
FoundationReviewerAgent,
FoundationReviewParseError,
} from "../agents/foundation-reviewer.js";
import { PolisherAgent } from "../agents/polisher.js";
import type { BookConfig } from "../models/book.js";
import type { ChapterMeta } from "../models/chapter.js";
@@ -120,9 +123,6 @@ function createReviseOutput(overrides: Partial<ReviseOutput> = {}): ReviseOutput
revisedContent: "Revised chapter body.",
wordCount: "Revised chapter body.".length,
fixedIssues: ["fixed"],
updatedState: "revised state",
updatedLedger: "revised ledger",
updatedHooks: "revised hooks",
tokenUsage: ZERO_USAGE,
...overrides,
};
@@ -143,6 +143,84 @@ function createAnalyzedOutput(overrides: Partial<WriteChapterOutput> = {}): Writ
});
}
function createSettledRevisionOutput(
input: SettleChapterStateInput,
overrides: Partial<WriteChapterOutput> = {},
): WriteChapterOutput {
const updatedState = createStateCard({
chapter: input.chapterNumber,
location: "Revision test location",
protagonistState: "Revision state settled from the new body.",
goal: "Continue the revised chapter direction.",
conflict: "Revision state remains internally consistent.",
});
const summaryRow = {
chapter: input.chapterNumber,
title: input.title,
characters: "Test protagonist",
events: "Revised chapter settled",
stateChanges: "State updated",
hookActivity: "No hook changes",
mood: "tense",
chapterType: "mainline",
};
return createWriterOutput({
chapterNumber: input.chapterNumber,
title: input.title,
content: input.content,
wordCount: input.content.length,
runtimeStateDelta: {
chapter: input.chapterNumber,
hookOps: { upsert: [], mention: [], resolve: [], defer: [] },
newHookCandidates: [],
chapterSummary: summaryRow,
subplotOps: [],
emotionalArcOps: [],
characterMatrixOps: [],
notes: [],
},
runtimeStateSnapshot: {
manifest: {
schemaVersion: 2,
language: input.book.language ?? "zh",
lastAppliedChapter: input.chapterNumber,
projectionVersion: 1,
migrationWarnings: [],
},
currentState: {
chapter: input.chapterNumber,
facts: [],
},
hooks: { hooks: [] },
chapterSummaries: { rows: [summaryRow] },
},
updatedState,
updatedHooks: "# Pending Hooks\n",
chapterSummary: `| ${input.chapterNumber} | ${input.title} | Test protagonist | Revised chapter settled | State updated | No hook changes | tense | mainline |`,
updatedChapterSummaries: `# Chapter Summaries\n\n| Chapter | Title | Characters | Key Events | State Changes | Hook Activity | Mood | Chapter Type |\n| --- | --- | --- | --- | --- | --- | --- | --- |\n| ${input.chapterNumber} | ${input.title} | Test protagonist | Revised chapter settled | State updated | No hook changes | tense | mainline |\n`,
...overrides,
});
}
async function snapshotRevisionBaseline(
state: StateManager,
bookId: string,
chapterNumber: number,
): Promise<void> {
const storyDir = join(state.bookDir(bookId), "story");
await readFile(join(storyDir, "current_state.md"), "utf-8").catch(() =>
writeFile(join(storyDir, "current_state.md"), createStateCard({
chapter: chapterNumber,
location: "Baseline location",
protagonistState: "Baseline protagonist state.",
goal: "Baseline goal.",
conflict: "Baseline conflict.",
}), "utf-8"));
await readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() =>
writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"));
await state.snapshotState(bookId, chapterNumber);
}
function createStateCard(params: {
readonly chapter: number;
readonly location: string;
@@ -303,6 +381,9 @@ describe("PipelineRunner", () => {
warnings: [],
passed: true,
});
vi.spyOn(WriterAgent.prototype, "settleChapterState").mockImplementation(
async (input) => createSettledRevisionOutput(input),
);
// Default reviser mock: return input content unchanged so the review cycle's
// repair loop exits immediately when triggered by length-out-of-range content.
// Tests that need specific revision behavior override this mock explicitly.
@@ -587,6 +668,55 @@ describe("PipelineRunner", () => {
}
});
it("keeps the current foundation when review formatting cannot be parsed", async () => {
const { root, runner, bookId } = await createRunnerFixture();
const reviewer = new FoundationReviewerAgent({
client: {
provider: "openai",
apiFormat: "chat",
stream: false,
} as ConstructorParameters<typeof PipelineRunner>[0]["client"],
model: "test-model",
projectRoot: root,
bookId,
});
const foundation = {
storyBible: "# Story Bible",
volumeOutline: "# Volume Outline",
bookRules: "# Book Rules",
currentState: "# Current State",
pendingHooks: "# Pending Hooks",
};
const generate = vi.fn(async () => foundation);
const reviewMock = vi.mocked(FoundationReviewerAgent.prototype.review);
reviewMock.mockReset();
reviewMock.mockRejectedValue(new FoundationReviewParseError([2, 3, 4, 5]));
try {
const result = await (runner as unknown as {
generateAndReviewFoundation: (params: {
readonly generate: () => Promise<typeof foundation>;
readonly reviewer: FoundationReviewerAgent;
readonly mode: "original";
readonly language: "zh";
readonly stageLanguage: "zh";
}) => Promise<typeof foundation>;
}).generateAndReviewFoundation({
generate,
reviewer,
mode: "original",
language: "zh",
stageLanguage: "zh",
});
expect(result).toBe(foundation);
expect(generate).toHaveBeenCalledTimes(1);
expect(reviewMock).toHaveBeenCalledTimes(1);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("honors configured foundation review retry count before accepting a rejected foundation", async () => {
const { root, runner, bookId } = await createRunnerFixture({
foundationReviewRetries: 4,
@@ -2788,11 +2918,15 @@ describe("PipelineRunner", () => {
const now = "2026-03-19T00:00:00.000Z";
const bookDir = state.bookDir(bookId);
const storyDir = join(bookDir, "story");
const baselineDir = join(storyDir, "snapshots", "0");
await mkdir(baselineDir, { recursive: true });
await Promise.all([
writeFile(join(storyDir, "current_state.md"), "stable state", "utf-8"),
writeFile(join(storyDir, "pending_hooks.md"), "stable hooks", "utf-8"),
writeFile(join(storyDir, "particle_ledger.md"), "stable ledger", "utf-8"),
writeFile(join(baselineDir, "current_state.md"), "baseline state", "utf-8"),
writeFile(join(baselineDir, "pending_hooks.md"), "baseline hooks", "utf-8"),
writeFile(
join(bookDir, "chapters", "0001_Broken_Persistence.md"),
"# 第1章 Broken Persistence\n\nHealthy chapter body with the copper token in his coat.",
@@ -2850,6 +2984,7 @@ describe("PipelineRunner", () => {
expect(result.chapterNumber).toBe(1);
expect(settleSpy).toHaveBeenCalledWith(expect.objectContaining({
allowReapply: true,
baselineChapter: 0,
}));
await expect(readFile(join(storyDir, "current_state.md"), "utf-8")).resolves.toBe("fixed state");
await expect(readFile(join(storyDir, "pending_hooks.md"), "utf-8")).resolves.toBe("fixed hooks");
@@ -2868,6 +3003,8 @@ describe("PipelineRunner", () => {
const now = "2026-03-19T00:00:00.000Z";
const bookDir = state.bookDir(bookId);
const storyDir = join(bookDir, "story");
const baselineDir = join(storyDir, "snapshots", "0");
await mkdir(baselineDir, { recursive: true });
await Promise.all([
writeFile(join(storyDir, "current_focus.md"), "# 当前聚焦\n\n## 当前重点\n\n商会路线优先。\n", "utf-8"),
@@ -2884,6 +3021,8 @@ describe("PipelineRunner", () => {
"| 1 | 夜灯 | 林越 | 林越继续追查师债 | 追查意图更强 | 师债推进 | 压抑 | 主线推进 |",
"",
].join("\n"), "utf-8"),
writeFile(join(baselineDir, "current_state.md"), "baseline state", "utf-8"),
writeFile(join(baselineDir, "pending_hooks.md"), "baseline hooks", "utf-8"),
writeFile(
join(bookDir, "chapters", "0001_夜灯.md"),
"# 第1章 夜灯\n\n林越推门进去,先停在门槛外听了一息,再去看柜台后那盏没关的灯。",
@@ -2936,6 +3075,7 @@ describe("PipelineRunner", () => {
expect(result.chapterNumber).toBe(1);
expect(settleSpy).toHaveBeenCalledWith(expect.objectContaining({
allowReapply: true,
baselineChapter: 0,
chapterIntent: expect.stringContaining("把注意力收回师债主线"),
}));
await expect(readFile(join(storyDir, "current_state.md"), "utf-8")).resolves.toBe("synced state");
@@ -3951,6 +4091,7 @@ describe("PipelineRunner", () => {
auditIssues: [],
lengthWarnings: [],
}]);
await snapshotRevisionBaseline(state, bookId, 0);
await state.snapshotState(bookId, 1);
vi.spyOn(ContinuityAuditor.prototype, "auditChapter")
@@ -3972,12 +4113,33 @@ describe("PipelineRunner", () => {
createReviseOutput({
revisedContent: "Revised body.",
wordCount: "Revised body.".length,
updatedState: revisedState,
updatedHooks: "# Pending Hooks\n",
}),
);
vi.spyOn(WriterAgent.prototype, "settleChapterState").mockImplementation(
async (input) => {
const output = createSettledRevisionOutput(input, { updatedState: revisedState });
return {
...output,
runtimeStateSnapshot: {
...output.runtimeStateSnapshot!,
currentState: {
chapter: 1,
facts: [{
subject: "protagonist",
predicate: "Current Conflict",
object: "The oath token is public now, forcing the confrontation.",
validFromChapter: 1,
validUntilChapter: null,
sourceChapter: 1,
}],
},
},
};
},
);
try {
await snapshotRevisionBaseline(state, bookId, 0);
await runner.reviseDraft(bookId, 1);
const memoryDb = new MemoryDB(state.bookDir(bookId));
@@ -4237,14 +4399,6 @@ describe("PipelineRunner", () => {
createReviseOutput({
revisedContent: revisedBody,
wordCount: revisedBody.length,
updatedState: createStateCard({
chapter: 1,
location: "Ashen ferry crossing",
protagonistState: "Lin Yue still hides the oath token.",
goal: "Find the vanished mentor.",
conflict: "He steps into the empty room.",
}),
updatedHooks: "# Pending Hooks\n",
}),
);
vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockResolvedValue(
@@ -4425,18 +4579,11 @@ describe("PipelineRunner", () => {
createReviseOutput({
revisedContent: "Spot-fixed body.",
wordCount: "Spot-fixed body.".length,
updatedState: createStateCard({
chapter: 1,
location: "Ashen ferry crossing",
protagonistState: "Lin Yue still hides the oath token.",
goal: "Find the vanished mentor.",
conflict: "The mentor debt is repaired.",
}),
updatedHooks: "# Pending Hooks\n",
}),
);
try {
await snapshotRevisionBaseline(state, bookId, 0);
await runner.reviseDraft(bookId, 1);
expect(reviseChapter).toHaveBeenCalledTimes(1);
@@ -4507,18 +4654,11 @@ describe("PipelineRunner", () => {
revisedContent: "林越推门进去,先停在门槛外听了一息,再去看柜台后那盏没关的灯。",
wordCount: "林越推门进去,先停在门槛外听了一息,再去看柜台后那盏没关的灯。".length,
fixedIssues: ["- 收紧了主线焦点。"],
updatedState: createStateCard({
chapter: 1,
location: "旧港便利店",
protagonistState: "林越把注意力重新拉回师债。",
goal: "继续追查师债。",
conflict: "商会路线暂时退居背景。",
}),
updatedHooks: "# 伏笔池\n\n- 师债线索仍未回收。\n",
}),
);
try {
await snapshotRevisionBaseline(state, bookId, 0);
await runner.reviseDraft(bookId, 1);
expect(auditChapter.mock.calls[0]?.[4]).toMatchObject({
@@ -4618,6 +4758,7 @@ describe("PipelineRunner", () => {
);
try {
await snapshotRevisionBaseline(state, bookId, 0);
await runner.reviseDraft(bookId, 1);
expect(reviseChapter.mock.calls[0]?.[6]).toMatchObject({
@@ -4696,6 +4837,7 @@ describe("PipelineRunner", () => {
);
try {
await snapshotRevisionBaseline(state, bookId, 0);
const result = await runner.reviseDraft(bookId, 1);
const savedChapter = await readFile(join(chaptersDir, "0001_Test_Chapter.md"), "utf-8");
const savedIndex = await state.loadChapterIndex(bookId);
@@ -4772,18 +4914,11 @@ describe("PipelineRunner", () => {
revisedContent: revisedBody,
wordCount: revisedBody.length,
fixedIssues: ["- 收紧了结尾节奏。"],
updatedState: createStateCard({
chapter: 1,
location: "Ashen ferry crossing",
protagonistState: "Lin Yue still hides the oath token.",
goal: "Find the vanished mentor.",
conflict: "The mentor debt sharpens into a direct threat.",
}),
updatedHooks: "# Pending Hooks\n",
}),
);
try {
await snapshotRevisionBaseline(state, bookId, 0);
const result = await runner.reviseDraft(bookId, 1);
const savedChapter = await readFile(join(chaptersDir, "0001_Test_Chapter.md"), "utf-8");
const savedIndex = await state.loadChapterIndex(bookId);
@@ -4835,17 +4970,11 @@ describe("PipelineRunner", () => {
revisedContent: revisedBody,
wordCount: revisedBody.length,
fixedIssues: ["- 调整了开场镜头。"],
updatedState: createStateCard({
chapter: 1,
location: "Ashen ferry crossing",
protagonistState: "Lin Yue still hides the oath token.",
goal: "Find the vanished mentor.",
conflict: "The mentor debt sharpens into a direct threat.",
}),
updatedHooks: "# Pending Hooks\n",
}),
);
await snapshotRevisionBaseline(fixture.state, fixture.bookId, 0);
return { ...fixture, chaptersDir, revisedBody };
}
@@ -4856,6 +4985,42 @@ describe("PipelineRunner", () => {
suggestion: "压缩一行解释。",
};
it("keeps chapter and truth files unchanged when revised-body settlement cannot validate", async () => {
const { root, runner, state, bookId, chaptersDir, revisedBody } = await createRevisionGateFixture("always");
const storyDir = join(state.bookDir(bookId), "story");
const originalChapter = await readFile(join(chaptersDir, "0001_Test_Chapter.md"), "utf-8");
const originalState = await readFile(join(storyDir, "current_state.md"), "utf-8");
const originalHooks = await readFile(join(storyDir, "pending_hooks.md"), "utf-8");
vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValueOnce(
createAuditResult({ passed: false, issues: [CRITICAL_ISSUE], summary: "needs revision" }),
);
vi.spyOn(StateValidatorAgent.prototype, "validate").mockResolvedValue({
passed: false,
repairRequired: true,
warnings: [{
category: "state-conflict",
description: "The derived hook board contradicts the revised body.",
}],
});
try {
const result = await runner.reviseDraft(bookId, 1, "rework", "Rewrite the chapter and sync state.");
expect(result.applied).toBe(false);
expect(result.skippedReason).toContain("state settlement did not validate");
expect(result.auditIssues).toEqual([
expect.objectContaining({ category: "state-validation" }),
]);
await expect(readFile(join(chaptersDir, "0001_Test_Chapter.md"), "utf-8")).resolves.toBe(originalChapter);
await expect(readFile(join(storyDir, "current_state.md"), "utf-8")).resolves.toBe(originalState);
await expect(readFile(join(storyDir, "pending_hooks.md"), "utf-8")).resolves.toBe(originalHooks);
expect(originalChapter).not.toContain(revisedBody);
await expect(listChapterVersions(state.bookDir(bookId), 1)).resolves.toEqual([]);
} finally {
await rm(root, { recursive: true, force: true });
}
}, SLOW_PIPELINE_TEST_TIMEOUT_MS);
it("applies a no-improvement manual revision when revisionGate is lenient", async () => {
const { root, runner, bookId, chaptersDir, revisedBody } = await createRevisionGateFixture("lenient");
@@ -5103,18 +5268,11 @@ describe("PipelineRunner", () => {
revisedContent: revisedBody,
wordCount: countChapterLength(revisedBody, "en_words"),
fixedIssues: ["- Synced the annexe beat and tightened the ending."],
updatedState: createStateCard({
chapter: 2,
location: "East annexe corridor",
protagonistState: "Taryn is pressed against the annexe door with the true key in hand.",
goal: "Open the annexe before the cart clears the court.",
conflict: "A forged key and rival searchers have turned lawful access into a trap.",
}),
updatedHooks: "# Pending Hooks\n",
}),
);
try {
await snapshotRevisionBaseline(state, bookId, 1);
const result = await runner.reviseDraft(bookId, 2);
const savedIndex = await state.loadChapterIndex(bookId);
@@ -5319,18 +5477,11 @@ describe("PipelineRunner", () => {
revisedContent: revisedBody,
wordCount: countChapterLength(revisedBody, "en_words"),
fixedIssues: ["- Tightened the berth discovery beat."],
updatedState: createStateCard({
chapter: 1,
location: "Dock Nine",
protagonistState: "Tarin still carries the sealed packet.",
goal: "Find Captain Voss.",
conflict: "The berth is wrong and the crew is missing.",
}),
updatedHooks: "# Pending Hooks\n",
}),
);
try {
await snapshotRevisionBaseline(state, bookId, 0);
await runner.reviseDraft(bookId, 1, "polish");
expect(reviseChapter).toHaveBeenCalledTimes(1);
@@ -5,7 +5,7 @@ import {
extractCollaboratorRows,
extractOpponentRows,
extractProtagonistRow,
extractRelevantThreads,
formatRelevantThreads,
} from "../agents/planner-context.js";
// Real column layouts match the production truth-file schemas under story/.
@@ -114,23 +114,26 @@ describe("extractOpponentRows / extractCollaboratorRows", () => {
});
});
describe("extractRelevantThreads", () => {
it("selects active hooks and subplots, filtering dormant/stale", () => {
const hooks = `
| hook_id | 状态 | 最近推进 |
|---------|------|----------|
| H001 | activating | ch38 |
| H002 | dormant | ch20 |
| H003 | partial_payoff | ch37 |
`;
describe("formatRelevantThreads", () => {
it("uses the unified retrieval result for hooks and keeps active subplots", () => {
const hooks = [
{
hookId: "H002",
startChapter: 0,
type: "单元案",
status: "deferred",
lastAdvancedChapter: 0,
expectedPayoff: "卷内回收",
notes: "本章明确激活的休眠种子",
},
];
const subplots = `
| S001 | 主线追查 | 推进 |
| S007 | 旁线 | 暂挂 |
`;
const threads = extractRelevantThreads(hooks, subplots);
expect(threads).toContain("H001");
expect(threads).toContain("H003");
expect(threads).not.toContain("H002");
const threads = formatRelevantThreads(hooks, subplots);
expect(threads).toContain("H002");
expect(threads).toContain("本章明确激活的休眠种子");
expect(threads).toContain("S001");
expect(threads).not.toContain("S007");
});
+120 -76
View File
@@ -39,30 +39,79 @@ describe("play agents", () => {
});
});
it("degrades invalid mutator output into a safe no-op mutation instead of throwing", async () => {
it("keeps host-owned turn metadata authoritative when mutator output drifts", async () => {
const agent = new PlayWorldMutatorAgent(ctx);
vi.spyOn(agent as unknown as { chat: PlayWorldMutatorAgent["chat"] }, "chat").mockResolvedValue({
content: JSON.stringify({ eventId: "", turn: -1, actionKind: "teleport" }),
} as never);
vi.spyOn(agent as any, "submitStructured").mockResolvedValue({});
// The chat agent must not hard-crash on bad model output: the bad enum falls back to "do",
// eventId is backfilled, and the turn degrades to a no-op rather than a thrown error.
const mutation = await agent.proposeMutation({
turn: 1,
input: "我打开导航",
action: { actionKind: "look", intent: "查看导航" },
context: "车内。",
});
expect(mutation.actionKind).toBe("do");
expect(mutation.actionKind).toBe("look");
expect(mutation.eventId).toBe("evt-1");
expect(mutation.turn).toBe(1);
expect(mutation.entities.upsert).toEqual([]);
});
it("repairs an empty mutator response instead of treating it as a completed state transition", async () => {
const agent = new PlayWorldMutatorAgent(ctx);
const submit = vi.spyOn(agent as any, "submitStructured")
.mockResolvedValueOnce({})
.mockResolvedValueOnce({
summary: "司机确认母亲说的是孩子的哮喘药。",
entities: [{
id: "actor_child",
type: "actor",
label: "哮喘儿童",
summary: "母亲怀里的孩子,缺的是他的哮喘药。",
}],
});
const mutation = await agent.proposeMutation({
turn: 4,
input: "问母亲孩子的药还能撑多久",
action: { actionKind: "say", intent: "询问孩子的药量" },
context: "actor_mother 是 actor_child 的母亲;缺的是孩子的哮喘药。",
language: "zh",
});
expect(submit).toHaveBeenCalledTimes(2);
expect(mutation).toMatchObject({
eventId: "evt-4",
turn: 4,
actionKind: "say",
blocked: false,
});
expect(mutation.entities.upsert[0]?.label).toBe("哮喘儿童");
});
it("returns a visible blocked no-op when mutator repair still has no state result", async () => {
const agent = new PlayWorldMutatorAgent(ctx);
const submit = vi.spyOn(agent as any, "submitStructured").mockResolvedValue({});
const mutation = await agent.proposeMutation({
turn: 3,
input: "继续问清楚",
action: { actionKind: "say", intent: "追问关键信息" },
context: "当前场景。",
language: "zh",
});
expect(submit).toHaveBeenCalledTimes(2);
expect(mutation).toMatchObject({
eventId: "evt-3",
turn: 3,
actionKind: "say",
blocked: true,
});
expect(mutation.blockedReason).toContain("状态变更");
});
it("uses placeholder examples in the Chinese mutator prompt instead of leaking concrete character names", async () => {
const agent = new PlayWorldMutatorAgent(ctx);
const chat = vi.spyOn(agent as unknown as { chat: PlayWorldMutatorAgent["chat"] }, "chat").mockResolvedValue({
content: JSON.stringify({ eventId: "evt-1", turn: 1, actionKind: "look" }),
} as never);
const submit = vi.spyOn(agent as any, "submitStructured").mockResolvedValue({ blocked: true, blockedReason: "测试" });
await agent.proposeMutation({
turn: 1,
@@ -72,7 +121,7 @@ describe("play agents", () => {
language: "zh",
});
const messages = chat.mock.calls[0]?.[0] as ReadonlyArray<{ readonly role: string; readonly content: string }>;
const messages = submit.mock.calls[0]?.[0] as ReadonlyArray<{ readonly role: string; readonly content: string }>;
const system = messages.find((message) => message.role === "system")?.content ?? "";
expect(system).not.toContain("周野");
expect(system).not.toContain("账房先生");
@@ -86,9 +135,7 @@ describe("play agents", () => {
it("treats actor_player as the reserved player id in the Chinese mutator prompt", async () => {
const agent = new PlayWorldMutatorAgent(ctx);
const chat = vi.spyOn(agent as unknown as { chat: PlayWorldMutatorAgent["chat"] }, "chat").mockResolvedValue({
content: JSON.stringify({ eventId: "evt-1", turn: 1, actionKind: "look" }),
} as never);
const submit = vi.spyOn(agent as any, "submitStructured").mockResolvedValue({ blocked: true, blockedReason: "测试" });
await agent.proposeMutation({
turn: 1,
@@ -98,7 +145,7 @@ describe("play agents", () => {
language: "zh",
});
const messages = chat.mock.calls[0]?.[0] as ReadonlyArray<{ readonly role: string; readonly content: string }>;
const messages = submit.mock.calls[0]?.[0] as ReadonlyArray<{ readonly role: string; readonly content: string }>;
const system = messages.find((message) => message.role === "system")?.content ?? "";
expect(system).toContain("actor_player");
expect(system).toContain("固定保留字");
@@ -107,9 +154,7 @@ describe("play agents", () => {
it("treats actor_player as the reserved player id in the English mutator prompt", async () => {
const agent = new PlayWorldMutatorAgent(ctx);
const chat = vi.spyOn(agent as unknown as { chat: PlayWorldMutatorAgent["chat"] }, "chat").mockResolvedValue({
content: JSON.stringify({ eventId: "evt-1", turn: 1, actionKind: "look" }),
} as never);
const submit = vi.spyOn(agent as any, "submitStructured").mockResolvedValue({ blocked: true, blockedReason: "test" });
await agent.proposeMutation({
turn: 1,
@@ -119,7 +164,7 @@ describe("play agents", () => {
language: "en",
});
const messages = chat.mock.calls[0]?.[0] as ReadonlyArray<{ readonly role: string; readonly content: string }>;
const messages = submit.mock.calls[0]?.[0] as ReadonlyArray<{ readonly role: string; readonly content: string }>;
const system = messages.find((message) => message.role === "system")?.content ?? "";
expect(system).toContain("The player entity id is fixed");
expect(system).toContain("actor_player");
@@ -128,9 +173,7 @@ describe("play agents", () => {
it("does not default to numeric meters when the world contract rejects panels or stats", async () => {
const agent = new PlayWorldMutatorAgent(ctx);
const chat = vi.spyOn(agent as unknown as { chat: PlayWorldMutatorAgent["chat"] }, "chat").mockResolvedValue({
content: JSON.stringify({ eventId: "evt-1", turn: 1, actionKind: "look" }),
} as never);
const submit = vi.spyOn(agent as any, "submitStructured").mockResolvedValue({ blocked: true, blockedReason: "测试" });
await agent.proposeMutation({
turn: 1,
@@ -143,7 +186,7 @@ describe("play agents", () => {
language: "zh",
});
const messages = chat.mock.calls[0]?.[0] as ReadonlyArray<{ readonly role: string; readonly content: string }>;
const messages = submit.mock.calls[0]?.[0] as ReadonlyArray<{ readonly role: string; readonly content: string }>;
const system = messages.find((message) => message.role === "system")?.content ?? "";
expect(system).toContain("世界契约禁止数值");
expect(system).toContain("不要输出 stateSlots");
@@ -156,9 +199,7 @@ describe("play agents", () => {
await mkdir(join(root, "prompt", "play"), { recursive: true });
await writeFile(join(root, "prompt", "play", "mutator.md"), "PROJECT MUTATOR OVERRIDE: honor lantern rarity by atmosphere.");
const agent = new PlayWorldMutatorAgent({ ...ctx, projectRoot: root });
const chat = vi.spyOn(agent as unknown as { chat: PlayWorldMutatorAgent["chat"] }, "chat").mockResolvedValue({
content: JSON.stringify({ eventId: "evt-1", turn: 1, actionKind: "look" }),
} as never);
const submit = vi.spyOn(agent as any, "submitStructured").mockResolvedValue({ blocked: true, blockedReason: "测试" });
await agent.proposeMutation({
turn: 1,
@@ -168,7 +209,7 @@ describe("play agents", () => {
language: "zh",
});
const messages = chat.mock.calls[0]?.[0] as ReadonlyArray<{ readonly role: string; readonly content: string }>;
const messages = submit.mock.calls[0]?.[0] as ReadonlyArray<{ readonly role: string; readonly content: string }>;
const system = messages.find((message) => message.role === "system")?.content ?? "";
expect(system).toContain("Prompt Pack Guidance");
expect(system).toContain("PROJECT MUTATOR OVERRIDE");
@@ -179,9 +220,7 @@ describe("play agents", () => {
it("keeps beat-writing methodology out of the mutator protocol prompt", async () => {
const agent = new PlayWorldMutatorAgent(ctx);
const chat = vi.spyOn(agent as unknown as { chat: PlayWorldMutatorAgent["chat"] }, "chat").mockResolvedValue({
content: JSON.stringify({ eventId: "evt-1", turn: 1, actionKind: "do" }),
} as never);
const submit = vi.spyOn(agent as any, "submitStructured").mockResolvedValue({ blocked: true, blockedReason: "测试" });
await agent.proposeMutation({
turn: 1,
@@ -191,11 +230,11 @@ describe("play agents", () => {
language: "zh",
});
const messages = chat.mock.calls[0]?.[0] as ReadonlyArray<{ readonly role: string; readonly content: string }>;
const messages = submit.mock.calls[0]?.[0] as ReadonlyArray<{ readonly role: string; readonly content: string }>;
const system = messages.find((message) => message.role === "system")?.content ?? "";
expect(system).not.toContain("只推进相邻一拍");
expect(system).not.toContain("不要替玩家越过过程");
expect(system).toContain("PlayMutation");
expect(system).toContain("submit_world_mutation");
});
it("renderer treats player negation and applied time as canonical", async () => {
@@ -211,24 +250,29 @@ describe("play agents", () => {
expect(prompt).toContain("suggestedActions");
});
it("renders the applied state as prose plus suggested actions", async () => {
it("renders from the authoritative pre-action context plus applied state", async () => {
const agent = new PlaySceneRendererAgent(ctx);
vi.spyOn(agent as unknown as { chat: PlaySceneRendererAgent["chat"] }, "chat").mockResolvedValue({
content: JSON.stringify({
sceneText: "车机屏幕亮了一下,常用地址统计弹出一行冷冰冰的数字。",
suggestedActions: ["继续翻看医院记录", "套徐晋安的话"],
}),
} as never);
const submit = vi.spyOn(agent as any, "submitStructured").mockResolvedValue({
sceneText: "车机屏幕亮了一下,常用地址统计弹出一行冷冰冰的数字。",
suggestedActions: ["继续翻看医院记录", "套徐晋安的话"],
});
await expect(agent.render({
input: "看导航",
action: { actionKind: "look", intent: "查看导航" },
mutationSummary: "发现新城花园 187 次。",
stateBrief: "证据:常用地址统计=seen。",
context: "当前实体名册:actor_husband [actor]: 丈夫。\n当前场景:丈夫仍坐在副驾驶。",
})).resolves.toMatchObject({
sceneText: expect.stringContaining("车机屏幕"),
suggestedActions: ["继续翻看医院记录", "套徐晋安的话"],
});
const messages = submit.mock.calls[0]?.[0] as ReadonlyArray<{ readonly role: string; readonly content: string }>;
const user = messages.find((message) => message.role === "user")?.content ?? "";
expect(user).toContain("本回合前的权威上下文");
expect(user).toContain("actor_husband [actor]: 丈夫");
expect(submit.mock.calls[0]?.[1]).toMatchObject({ name: "submit_play_scene" });
});
it("loads project Play prompt-pack overrides into the renderer system prompt", async () => {
@@ -237,12 +281,10 @@ describe("play agents", () => {
await mkdir(join(root, "prompt", "play"), { recursive: true });
await writeFile(join(root, "prompt", "play", "renderer.md"), "PROJECT RENDERER OVERRIDE: render romance props through distance and touch.");
const agent = new PlaySceneRendererAgent({ ...ctx, projectRoot: root });
const chat = vi.spyOn(agent as unknown as { chat: PlaySceneRendererAgent["chat"] }, "chat").mockResolvedValue({
content: JSON.stringify({
sceneText: "她把那枚旧钥匙放回掌心。",
suggestedActions: [],
}),
} as never);
const submit = vi.spyOn(agent as any, "submitStructured").mockResolvedValue({
sceneText: "她把那枚旧钥匙放回掌心。",
suggestedActions: [],
});
await agent.render({
input: "我看那把旧钥匙",
@@ -251,7 +293,7 @@ describe("play agents", () => {
stateBrief: "物件:旧钥匙。",
});
const messages = chat.mock.calls[0]?.[0] as ReadonlyArray<{ readonly role: string; readonly content: string }>;
const messages = submit.mock.calls[0]?.[0] as ReadonlyArray<{ readonly role: string; readonly content: string }>;
const system = messages.find((message) => message.role === "system")?.content ?? "";
expect(system).toContain("Prompt Pack Guidance");
expect(system).toContain("PROJECT RENDERER OVERRIDE");
@@ -260,49 +302,34 @@ describe("play agents", () => {
}
});
it("renderer fails open: non-JSON output becomes the scene instead of throwing", async () => {
it("renderer rejects missing structured scene output instead of inventing a placeholder", async () => {
const agent = new PlaySceneRendererAgent(ctx);
// Model returned prose, not JSON — must degrade to using it as the scene, not crash the turn.
vi.spyOn(agent as unknown as { chat: PlaySceneRendererAgent["chat"] }, "chat").mockResolvedValue({
content: "雨还在下,她没有抬头,只是把书往自己那边挪了挪。",
} as never);
const result = await agent.render({
vi.spyOn(agent as any, "submitStructured").mockRejectedValue(new Error("Model did not submit_play_scene"));
await expect(agent.render({
input: "我看着她",
action: { actionKind: "look", intent: "看她" },
mutationSummary: "",
stateBrief: "",
});
expect(result.sceneText).toContain("雨还在下");
expect(result.suggestedActions).toEqual([]);
})).rejects.toThrow("submit_play_scene");
});
it("renderer fails open on a transient upstream error instead of crashing the turn", async () => {
it("renderer rejects an empty structured scene instead of committing fake prose", async () => {
const agent = new PlaySceneRendererAgent(ctx);
vi.spyOn(agent as unknown as { chat: PlaySceneRendererAgent["chat"] }, "chat").mockRejectedValue(
new Error("502 Bad Gateway"),
);
const result = await agent.render({
vi.spyOn(agent as any, "submitStructured").mockResolvedValue({ sceneText: "", suggestedActions: [] });
await expect(agent.render({
input: "我推门进去",
action: { actionKind: "move", intent: "进门" },
mutationSummary: "",
stateBrief: "",
});
// Degraded to a placeholder scene — a thrown error here would break (and half-commit) the turn.
expect(result.sceneText.length).toBeGreaterThan(0);
expect(result.suggestedActions).toEqual([]);
})).rejects.toThrow();
});
it("reconciler extracts supplemental graph facts from rendered prose", async () => {
const agent = new PlaySceneReconcilerAgent(ctx);
vi.spyOn(agent as unknown as { chat: PlaySceneReconcilerAgent["chat"] }, "chat").mockResolvedValue({
content: JSON.stringify({
eventId: "evt-2",
turn: 2,
actionKind: "look",
summary: "补记黑色U盘。",
entities: { upsert: [{ id: "item_black_usb", type: "item", label: "黑色U盘", status: "已发现" }] },
}),
} as never);
vi.spyOn(agent as any, "submitStructured").mockResolvedValue({
summary: "补记黑色U盘。",
entities: [{ id: "item_black_usb", type: "item", label: "黑色U盘", status: "已发现" }],
});
const mutation = PlayMutationSchema.parse(await agent.reconcile({
turn: 2,
@@ -318,11 +345,27 @@ describe("play agents", () => {
expect(mutation.entities.upsert[0]?.label).toBe("黑色U盘");
});
it("keeps reconciler event metadata host-owned", async () => {
const agent = new PlaySceneReconcilerAgent(ctx);
vi.spyOn(agent as any, "submitStructured").mockResolvedValue({ summary: "补记母子关系。" });
const mutation = PlayMutationSchema.parse(await agent.reconcile({
turn: 4,
input: "问母亲孩子的药还能撑多久",
action: { actionKind: "say", intent: "询问孩子的药量" },
mutation: { eventId: "evt-4", turn: 4, actionKind: "say", summary: "追问药量。" },
sceneText: "母亲低头看了一眼怀里的孩子。",
context: "actor_mother 是 actor_child 的母亲。",
stateBrief: "# Play State\n- summary: 追问药量。\n",
language: "zh",
}));
expect(mutation).toMatchObject({ eventId: "evt-4", turn: 4, actionKind: "say" });
});
it("reconciler fails open to an empty supplement on malformed output", async () => {
const agent = new PlaySceneReconcilerAgent(ctx);
vi.spyOn(agent as unknown as { chat: PlaySceneReconcilerAgent["chat"] }, "chat").mockResolvedValue({
content: "没有需要补充的内容。",
} as never);
vi.spyOn(agent as any, "submitStructured").mockRejectedValue(new Error("model did not submit tool"));
const mutation = PlayMutationSchema.parse(await agent.reconcile({
turn: 2,
@@ -359,6 +402,7 @@ describe("scene renderer prompt by mode", () => {
const prompt = buildSceneRendererSystemPrompt("guided");
expect(prompt).toContain("具体的新物件");
expect(prompt).toContain("必须先由 mutator 建成实体");
expect(prompt).toContain("已经完成的玩家动作逐项写出来");
});
it("open 模式不强制选项数量", () => {
@@ -187,6 +187,46 @@ describe("PlayRunner", () => {
.resolves.toContain("屏幕弹出新城花园 187 次");
});
it("does not commit state when scene rendering fails", async () => {
const db = new FakePlayDB();
const runner = new PlayRunner({
projectRoot: root,
worldId: "render-failure",
runId: "main",
db,
agents: {
actionInterpreter: {
interpret: vi.fn(async () => ({ actionKind: "look" as const, intent: "检查封条" })),
},
worldMutator: {
proposeMutation: vi.fn(async () => ({
eventId: "evt-1",
turn: 1,
actionKind: "look" as const,
summary: "玩家发现封条有新划痕。",
entities: {
upsert: [{ id: "evidence_seal", type: "evidence" as const, label: "封条划痕" }],
},
})),
},
sceneRenderer: {
render: vi.fn(async () => {
throw new Error("Model did not submit_play_scene");
}),
},
},
});
await expect(runner.step("检查封条")).rejects.toThrow("submit_play_scene");
expect(db.events).toHaveLength(0);
expect(db.entities.has("evidence_seal")).toBe(false);
const runDir = join(root, "worlds", "render-failure", "runs", "main");
await expect(readFile(join(runDir, "events.jsonl"), "utf-8")).rejects.toThrow();
await expect(readFile(join(runDir, "projections", "scene.md"), "utf-8")).rejects.toThrow();
await expect(readFile(join(runDir, "transcript.jsonl"), "utf-8")).rejects.toThrow();
});
it("seeds opening graph state without consuming the first player turn", async () => {
const db = new FakePlayDB();
const store = new PlayStore(root);
@@ -252,6 +292,72 @@ describe("PlayRunner", () => {
.toContain("无名婴儿照片");
});
it("reconciles opening prose into the graph when the opening mutator returns no facts", async () => {
const db = new FakePlayDB();
const store = new PlayStore(root);
await store.createWorld({
id: "opening-reconcile",
title: "零点十七分的隧道",
premise: "玩家是公交司机,对车上十名乘客负责。一个哮喘儿童由母亲抱着,老人要回去拿胰岛素。",
language: "zh",
});
await store.ensureRun("opening-reconcile", "main");
const reconcile = vi.fn(async () => ({
eventId: "evt-0",
turn: 0,
actionKind: "look" as const,
summary: "补记开场已经出现的司机、母亲、孩子和老人。",
entities: {
upsert: [
{ id: "actor_player", type: "actor" as const, label: "公交司机", summary: "对十名乘客负责。", updatedEventId: "evt-0" },
{ id: "actor_mother", type: "actor" as const, label: "母亲", summary: "抱着哮喘儿童。", updatedEventId: "evt-0" },
{ id: "actor_child", type: "actor" as const, label: "哮喘儿童", summary: "缺的是他的哮喘药。", updatedEventId: "evt-0" },
{ id: "actor_elder", type: "actor" as const, label: "老人", summary: "需要取回自己的胰岛素。", updatedEventId: "evt-0" },
],
},
edges: {
upsert: [{
id: "edge_actor_mother_照顾_actor_child",
fromId: "actor_mother",
type: "照顾",
toId: "actor_child",
value: { role: "relation" },
validFromEventId: "evt-0",
sourceEventId: "evt-0",
}],
},
}));
const runner = new PlayRunner({
projectRoot: root,
worldId: "opening-reconcile",
runId: "main",
store,
db,
agents: {
actionInterpreter: { interpret: vi.fn(async () => ({ actionKind: "look", intent: "开场播种" })) },
worldMutator: {
proposeMutation: vi.fn(async () => ({ eventId: "evt-0", turn: 0, actionKind: "look" })),
},
sceneRenderer: { render: vi.fn(async () => ({ sceneText: "不会被调用", suggestedActions: [] })) },
sceneReconciler: { reconcile },
},
});
const sceneText = "你是司机。第三排的哮喘儿童被母亲抱着,老人说胰岛素落在行李舱。";
const result = await runner.seedOpening({ sceneText, suggestedActions: [] });
expect(reconcile).toHaveBeenCalledWith(expect.objectContaining({
turn: 0,
sceneText,
context: expect.stringContaining("十名乘客"),
}));
expect(result?.mutation.entities.upsert).toHaveLength(4);
expect(db.entities.get("actor_child")?.summary).toContain("他的哮喘药");
expect([...db.edges.values()].some((edge) => edge.fromId === "actor_mother" && edge.toId === "actor_child")).toBe(true);
expect(db.events).toHaveLength(0);
});
it("tells the opening seeder to turn already-held objects into holding edges", async () => {
const db = new FakePlayDB();
const store = new PlayStore(root);
@@ -433,6 +539,7 @@ describe("PlayRunner", () => {
expect(mutatorContext).toContain("org_tieshou_escort [organization]: 铁手镖队");
expect(renderSpy).toHaveBeenCalledWith(expect.objectContaining({
worldPremise: expect.stringContaining("世界契约"),
context: expect.stringContaining("actor_laochen [actor]: 老陈"),
}));
});
@@ -26,6 +26,13 @@ describe("prompt pack loader", () => {
expect(loaded.source).toBe("builtin");
expect(loaded.content).toContain("long-form");
expect(loaded.promptId).toBe("longform.writer");
expect(loaded.content).toContain("exact placement");
expect(loaded.content).toContain("Reuse supplied hook ids");
});
it("keeps longform audit and revision focused on binding intent before style", () => {
expect(getBuiltinPrompt("longform.auditor")?.content).toContain("critical structural failure");
expect(getBuiltinPrompt("longform.reviser")?.content).toContain("critical author-intent and canon issue");
});
it("uses project override before user override and built-in", async () => {
+68 -9
View File
@@ -588,7 +588,7 @@ describe("chatCompletion via pi-ai", () => {
vi.unstubAllGlobals();
});
it("uses reasoning_content for custom openai-compatible non-stream responses that omit content", async () => {
it("rejects reasoning-only custom non-stream responses instead of treating thinking as final text", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
@@ -607,15 +607,14 @@ describe("chatCompletion via pi-ai", () => {
baseUrl: "https://gateway.example/v1",
},
});
const result = await chatCompletion(client, "glm-compat", [{ role: "user", content: "nihao" }]);
expect(result.content).toBe("推理通道文本");
await expect(chatCompletion(client, "glm-compat", [{ role: "user", content: "nihao" }], { retry: false }))
.rejects.toThrow(/final answer|empty response/i);
expect(fetchMock).toHaveBeenCalledOnce();
vi.unstubAllGlobals();
});
it("uses reasoning_content for custom openai-compatible streams that omit content deltas", async () => {
it("rejects reasoning-only custom streams instead of persisting thinking as final text", async () => {
const encoder = new TextEncoder();
const sse = [
"data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"你\"}}]}\n\n",
@@ -643,15 +642,47 @@ describe("chatCompletion via pi-ai", () => {
baseUrl: "https://gateway.example/v1",
},
});
const result = await chatCompletion(client, "glm-compat", [{ role: "user", content: "nihao" }]);
expect(result.content).toBe("你好");
expect(result.usage.totalTokens).toBe(5);
await expect(chatCompletion(client, "glm-compat", [{ role: "user", content: "nihao" }], { retry: false }))
.rejects.toThrow(/final answer|empty response/i);
expect(fetchMock).toHaveBeenCalledOnce();
vi.unstubAllGlobals();
});
it("retries a reasoning-only non-stream response inside the same model stage", async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
choices: [{ message: { reasoning_content: "先分析但没有最终答案" } }],
usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 },
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
choices: [{ message: { content: "完整最终答案" } }],
usage: { prompt_tokens: 3, completion_tokens: 4, total_tokens: 7 },
}),
});
vi.stubGlobal("fetch", fetchMock);
const client = makeClient(0.7, {
service: "custom",
stream: false,
_piModel: {
...MOCK_PI_MODEL,
provider: "openai",
baseUrl: "https://gateway.example/v1",
},
});
const result = await chatCompletion(client, "glm-compat", [{ role: "user", content: "nihao" }]);
expect(result.content).toBe("完整最终答案");
expect(fetchMock).toHaveBeenCalledTimes(2);
vi.unstubAllGlobals();
});
it("retries custom openai-compatible chat by folding system messages into user when system role is unsupported", async () => {
const fetchMock = vi.fn()
.mockResolvedValueOnce({
@@ -1201,6 +1232,21 @@ describe("stream interruption detection", () => {
vi.unstubAllGlobals();
});
it("rejects a native chat stream that reaches the output limit", async () => {
const sse = [
"data: {\"choices\":[{\"delta\":{\"content\":\"写到上限的正文\"}}]}\n\n",
"data: {\"choices\":[{\"delta\":{},\"finish_reason\":\"length\"}]}\n\n",
"data: [DONE]\n\n",
].join("");
const fetchMock = vi.fn().mockImplementation(async () => sseResponse(sse));
vi.stubGlobal("fetch", fetchMock);
await expect(chatCompletion(nativeStreamClient(), "glm-compat", [{ role: "user", content: "写正文" }]))
.rejects.toThrow(/output limit|length|Stream interrupted/i);
expect(fetchMock).toHaveBeenCalledTimes(3);
vi.unstubAllGlobals();
});
it("retries a pi-ai stream that errors after a long partial instead of silently keeping the truncation", async () => {
const longPartial = "长".repeat(600);
const partialMsg = makeAssistantMessage(longPartial);
@@ -1242,4 +1288,17 @@ describe("stream interruption detection", () => {
expect(result.content).toBe("第二次完整");
expect(mockStreamSimple).toHaveBeenCalledTimes(2);
});
it("rejects a pi-ai done message whose stop reason is length", async () => {
const partial = makeAssistantMessage("只写到一半");
const lengthMessage = { ...partial, stopReason: "length" } as AssistantMessage;
mockStreamSimple.mockImplementation(() => makeEventStream([
{ type: "text_delta", contentIndex: 0, delta: "只写到一半", partial },
{ type: "done", reason: "length", message: lengthMessage },
]) as never);
await expect(chatCompletion(makeClient(), "test-model", [{ role: "user", content: "写" }]))
.rejects.toThrow(/output limit|length|Stream interrupted/i);
expect(mockStreamSimple).toHaveBeenCalledTimes(3);
});
});
+6 -1
View File
@@ -82,7 +82,7 @@ describe("ReviserAgent", () => {
});
try {
await agent.reviseChapter(bookDir, "Original chapter content.", 1, [CRITICAL_ISSUE], "rewrite", "xuanhuan");
const output = await agent.reviseChapter(bookDir, "Original chapter content.", 1, [CRITICAL_ISSUE], "rewrite", "xuanhuan");
const messages = chatSpy.mock.calls[0]?.[0] as
| ReadonlyArray<{ content: string }>
@@ -91,6 +91,10 @@ describe("ReviserAgent", () => {
expect(systemPrompt).toContain("MUST be in English");
expect(systemPrompt).toContain("PROJECT REVISER OVERRIDE");
expect(systemPrompt).not.toContain("=== UPDATED_STATE ===");
expect(systemPrompt).not.toContain("=== UPDATED_HOOKS ===");
expect("updatedState" in output).toBe(false);
expect("updatedHooks" in output).toBe(false);
} finally {
await rm(root, { recursive: true, force: true });
}
@@ -358,6 +362,7 @@ describe("ReviserAgent", () => {
category: "套话密度",
description: "仿佛用得太直接",
suggestion: "改成更具体的感官描写",
repairScope: "local",
}],
"auto",
"xuanhuan",
@@ -6,6 +6,7 @@ import {
buildRuntimeStateArtifacts,
loadNarrativeMemorySeed,
loadRuntimeStateSnapshot,
loadRuntimeStateSnapshotAtChapter,
loadSnapshotCurrentStateFacts,
} from "../state/runtime-state-store.js";
@@ -223,6 +224,54 @@ describe("runtime-state-store memory helpers", () => {
]);
});
it("reconstructs a pre-chapter runtime snapshot from markdown without losing stable hook ids", async () => {
root = await mkdtemp(join(tmpdir(), "inkos-runtime-revision-baseline-"));
const bookDir = join(root, "book");
const snapshotDir = join(bookDir, "story", "snapshots", "0");
await mkdir(snapshotDir, { recursive: true });
await Promise.all([
writeFile(
join(snapshotDir, "current_state.md"),
[
"# Current State",
"",
"| Field | Value |",
"| --- | --- |",
"| Current Chapter | 0 |",
"| Current Location | Clock repair shop |",
"| Current Conflict | Sun Yuzhen disputes the twenty-minute clock drift. |",
"",
].join("\n"),
"utf-8",
),
writeFile(
join(snapshotDir, "pending_hooks.md"),
[
"| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |",
"| --- | --- | --- | --- | --- | --- | --- |",
"| H012 | 0 | mystery | 暂缓 | 0 | Explain the clock drift. | Sun Yuzhen owns the clock. |",
"",
].join("\n"),
"utf-8",
),
]);
const snapshot = await loadRuntimeStateSnapshotAtChapter({
bookDir,
chapterNumber: 0,
language: "en",
});
expect(snapshot.manifest.lastAppliedChapter).toBe(0);
expect(snapshot.hooks.hooks).toEqual([
expect.objectContaining({
hookId: "H012",
status: "deferred",
notes: "Sun Yuzhen owns the clock.",
}),
]);
});
it("rejects persisted duplicate summary chapters in structured runtime state", async () => {
root = await mkdtemp(join(tmpdir(), "inkos-runtime-state-invalid-"));
const bookDir = join(root, "book");
@@ -349,7 +398,7 @@ describe("runtime-state-store memory helpers", () => {
expect(snapshot.manifest.migrationWarnings.join("\n")).toContain("empty hook type");
});
it("arbitrates new hook candidates before applying structured state updates", async () => {
it("canonicalizes structurally valid new hook candidates without semantic guessing", async () => {
root = await mkdtemp(join(tmpdir(), "inkos-runtime-state-arbiter-"));
const bookDir = join(root, "book");
const storyDir = join(bookDir, "story");
@@ -424,14 +473,12 @@ describe("runtime-state-store memory helpers", () => {
expect(artifacts.resolvedDelta.hookOps.upsert).toEqual([
expect.objectContaining({
hookId: "anonymous-source-scope",
hookId: expect.not.stringMatching(/^anonymous-source-scope$/),
startChapter: 12,
lastAdvancedChapter: 12,
}),
]);
expect(artifacts.snapshot.hooks.hooks).toHaveLength(1);
expect(artifacts.snapshot.hooks.hooks[0]).toEqual(expect.objectContaining({
hookId: "anonymous-source-scope",
lastAdvancedChapter: 12,
}));
expect(artifacts.snapshot.hooks.hooks).toHaveLength(2);
expect(artifacts.snapshot.hooks.hooks.map((hook) => hook.hookId)).toContain("anonymous-source-scope");
});
});
@@ -11,17 +11,72 @@ import type { AgentContext } from "../agents/base.js";
import { loadStoryGraph } from "../interactive-film/graph-store.js";
const chatCompletionMock = vi.hoisted(() => vi.fn());
const generateStoryGraphMock = vi.hoisted(() => vi.fn());
vi.mock("../llm/provider.js", () => ({
chatCompletion: chatCompletionMock,
}));
vi.mock("../interactive-film/generate.js", () => ({
generateStoryGraph: generateStoryGraphMock,
}));
describe("storyboard creation runner", () => {
let root: string;
beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), "inkos-storyboard-assets-"));
chatCompletionMock.mockReset();
generateStoryGraphMock.mockReset();
generateStoryGraphMock.mockImplementation((
_client: unknown,
_model: string,
input: { projectId: string; title: string },
options?: { language?: "zh" | "en" },
) => {
const en = options?.language === "en";
return Promise.resolve({
schemaVersion: 1,
projectId: input.projectId,
title: input.title,
variables: [],
nodes: [
{
id: "start",
title: en ? "Opening" : "开场",
type: "start",
sceneDesc: en ? "The choice begins." : "抉择开始。",
dialogue: [],
choices: [{ id: "c1", text: en ? "Proceed" : "继续", targetNodeId: "branch-1", effects: [] }],
},
{
id: "branch-1",
title: en ? "First Choice" : "第一次选择",
type: "branch",
sceneDesc: en ? "Evidence surfaces." : "证据出现。",
dialogue: [],
choices: [
{ id: "c2", text: en ? "Reveal" : "公开", targetNodeId: "branch-2", effects: [] },
{ id: "c3", text: en ? "Hide" : "隐瞒", targetNodeId: "ending-secret", effects: [] },
],
},
{
id: "branch-2",
title: en ? "Final Choice" : "最终选择",
type: "branch",
sceneDesc: en ? "The truth demands a cost." : "真相要求代价。",
dialogue: [],
choices: [{ id: "c4", text: en ? "Publish" : "公布", targetNodeId: "ending-good", effects: [] }],
},
{ id: "ending-good", title: en ? "Truth" : "真相", type: "ending", sceneDesc: "", dialogue: [], choices: [] },
{ id: "ending-secret", title: en ? "Silence" : "沉默", type: "ending", sceneDesc: "", dialogue: [], choices: [] },
],
endings: [
{ id: "good", nodeId: "ending-good", title: en ? "Truth" : "真相", type: "good", description: "" },
{ id: "secret", nodeId: "ending-secret", title: en ? "Silence" : "沉默", type: "secret", description: "" },
],
});
});
chatCompletionMock.mockResolvedValue({
content: [
"# 冷库账页 分镜",
@@ -97,6 +152,116 @@ describe("storyboard creation runner", () => {
expect(messages[0]?.content).not.toContain("inkos-long-writing");
});
it("generates large episodic storyboards in complete structural segments", async () => {
chatCompletionMock.mockReset();
for (const episode of [1, 2, 3]) {
chatCompletionMock.mockResolvedValueOnce({
content: [
`# 风眼来电 第${episode}集分镜`,
"",
"## 分镜表",
`镜头 ${episode}:第${episode}集完整镜头。`,
"",
"## 图像提示词",
`Prompt: 第${episode}集写实冷峻画面,9:16`,
].join("\n"),
usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
});
}
const result = await runStoryboardCreation({
projectRoot: root,
runtime: makeRuntime(root),
title: "风眼来电分镜",
instruction: "总计 81 镜;第1集 28 镜、第2集 27 镜、第3集 26 镜。",
requirements: "保留证据特写与跨集连续性。",
sourceText: [
"# 风眼来电",
"",
"### 第1集《旧频率》",
"第一集完整正文。",
"",
"### 第2集《抄页》",
"第二集完整正文。",
"",
"### 第3集《赴约》",
"第三集完整正文。",
].join("\n"),
maxShots: 81,
projectId: "storm-eye-storyboard",
});
expect(chatCompletionMock).toHaveBeenCalledTimes(3);
for (const [index, call] of chatCompletionMock.mock.calls.entries()) {
const messages = call[2] as ReadonlyArray<{ role: string; content: string }>;
const prompt = messages[1]!.content;
expect(prompt).toContain(`${index + 1}`);
expect(prompt).toContain("总计 81 镜");
expect(prompt).toContain(`${index + 1}/3`);
if (index > 0) expect(prompt).not.toContain("第一集完整正文");
if (index < 2) expect(prompt).not.toContain("第三集完整正文");
}
const storyboard = await readFile(join(root, result.storyboardPath), "utf-8");
expect(storyboard).toContain("第1集完整镜头");
expect(storyboard).toContain("第2集完整镜头");
expect(storyboard).toContain("第3集完整镜头");
const manifest = JSON.parse(
await readFile(join(root, result.assetsManifestPath), "utf-8"),
) as StoryboardAssetsManifest;
expect(manifest.assets).toHaveLength(3);
});
it("subdivides oversized episodes by explicit Markdown scene structure without dropping source", async () => {
chatCompletionMock.mockReset();
for (const segment of ["一场", "二场", "一集钩子", "三场", "四场", "二集钩子"]) {
chatCompletionMock.mockResolvedValueOnce({
content: `## 分镜表\n${segment}\n\n## 图像提示词\nPrompt: ${segment}画面`,
usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
});
}
const sourceText = [
"# 风眼来电",
"### 第1集《旧频率》",
"**场次1:广播室/夜/内**",
"第一场唯一正文。",
"**场次2:值班室/夜/内**",
"第二场唯一正文。",
"**集尾钩子**",
"第一集钩子唯一正文。",
"### 第2集《抄页》",
"**场次1:广播室/夜/内**",
"第三场唯一正文。",
"**场次2:码头/夜/外**",
"第四场唯一正文。",
"**集尾钩子**",
"第二集钩子唯一正文。",
].join("\n");
await runStoryboardCreation({
projectRoot: root,
runtime: makeRuntime(root),
title: "风眼来电分镜",
instruction: "总计 60 镜,各场按确认数量执行。",
sourceText,
maxShots: 60,
projectId: "storm-eye-scenes",
});
expect(chatCompletionMock).toHaveBeenCalledTimes(6);
const prompts = chatCompletionMock.mock.calls.map((call) =>
(call[2] as ReadonlyArray<{ content: string }>)[1]!.content);
expect(prompts[0]).toContain("第一场唯一正文");
expect(prompts[0]).not.toContain("第二场唯一正文");
expect(prompts[2]).toContain("第一集钩子唯一正文");
expect(prompts[3]).toContain("第三场唯一正文");
expect(prompts[5]).toContain("第二集钩子唯一正文");
expect(prompts.join("\n")).toContain("全局镜头上限不是本次镜头数");
for (const call of chatCompletionMock.mock.calls) {
expect(call[3]).toMatchObject({ maxTokens: 18_000 });
}
});
it("writes interactive-film story tree, flags, script, storyboard, prompts, and image assets", async () => {
chatCompletionMock.mockResolvedValueOnce({
content: [
@@ -329,7 +494,8 @@ describe("storyboard creation runner", () => {
expect(graph.nodes.find((node) => node.id === "start")?.title).toBe("Opening");
});
it("falls back to a loadable story graph when graph JSON generation fails", async () => {
it("fails clearly when the structured story graph worker cannot submit a graph", async () => {
generateStoryGraphMock.mockRejectedValueOnce(new Error("model did not submit a graph"));
chatCompletionMock.mockResolvedValueOnce({
content: [
"# 回声剧场 互动影游方案",
@@ -355,22 +521,15 @@ describe("storyboard creation runner", () => {
usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 },
});
const result = await runInteractiveFilmCreation({
await expect(runInteractiveFilmCreation({
projectRoot: root,
runtime: makeRuntime(root),
title: "回声剧场",
instruction: "做一个悬疑互动影游。",
projectId: "echo-theater",
episodeCount: 3,
});
expect(result.storyGraphPath).toBe("interactive-films/echo-theater/story-graph.json");
const graph = await loadStoryGraph(root, "echo-theater");
expect(graph).not.toBeNull();
if (!graph) throw new Error("Expected fallback story graph");
expect(graph.title).toBe("回声剧场");
expect(graph.nodes.some((node) => node.type === "start")).toBe(true);
expect(graph.endings.length).toBeGreaterThanOrEqual(2);
})).rejects.toThrow("model did not submit a graph");
await expect(loadStoryGraph(root, "echo-theater")).resolves.toBeNull();
});
});
@@ -122,6 +122,21 @@ describe("script and storyboard creation helpers", () => {
].join("\n"));
});
it("extracts inline-code Prompt lines emitted by storyboard models", () => {
const prompts = extractStoryboardImagePrompts([
"# 风眼旧频率",
"",
"## 分镜与图像提示词",
"`Prompt: 写实台风海岛,渡船广播室,冷青雨夜,16:9`",
"`Prompt: 走私船舱,妹妹敲击暗号,低照度写实电影感`",
].join("\n"));
expect(prompts).toBe([
"1. 写实台风海岛,渡船广播室,冷青雨夜,16:9",
"2. 走私船舱,妹妹敲击暗号,低照度写实电影感",
].join("\n"));
});
it("extracts prompts from markdown tables with a Prompt column", () => {
const prompts = extractStoryboardImagePrompts([
"# 雾桥旅馆",
@@ -0,0 +1,60 @@
import { describe, expect, it } from "vitest";
import type { BookConfig } from "../models/book.js";
import type { GenreProfile } from "../models/genre-profile.js";
import { buildSettlerSystemPrompt, buildSettlerUserPrompt } from "../agents/settler-prompts.js";
const BOOK: BookConfig = {
id: "settler-prompt-book",
title: "钟不撒谎",
platform: "other",
genre: "mystery",
status: "active",
targetChapters: 20,
chapterWordCount: 2500,
createdAt: "2026-08-15T00:00:00.000Z",
updatedAt: "2026-08-15T00:00:00.000Z",
};
const GENRE: GenreProfile = {
id: "mystery",
name: "悬疑",
language: "zh",
chapterTypes: ["调查"],
fatigueWords: [],
numericalSystem: false,
powerScaling: false,
eraResearch: false,
pacingRule: "",
satisfactionTypes: [],
auditDimensions: [],
};
describe("settler hook identity contract", () => {
it("assigns semantic identity to the settler and keeps host admission structural", () => {
const prompt = buildSettlerSystemPrompt(BOOK, GENRE, null, "zh");
expect(prompt).toContain("语义相关的休眠种子");
expect(prompt).toContain("必须复用它已有的 hookId");
expect(prompt).toContain("宿主只校验结构");
expect(prompt).not.toContain("由系统决定它是映射到旧 hook");
});
it("labels supplied hooks as active or semantically relevant dormant canon", () => {
const prompt = buildSettlerUserPrompt({
chapterNumber: 1,
title: "慢了十一分钟",
content: "孙玉珍抱钟进店。",
currentState: "# 当前状态",
ledger: "",
hooks: "| H012 | deferred | 孙玉珍家座钟被回拨 |",
chapterSummaries: "(文件尚未创建)",
subplotBoard: "(文件尚未创建)",
emotionalArcs: "(文件尚未创建)",
characterMatrix: "(文件尚未创建)",
volumeOutline: "# 第一卷",
});
expect(prompt).toContain("含活跃伏笔与本章语义相关的休眠种子");
expect(prompt).toContain("H012");
});
});
@@ -4,6 +4,8 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import type { LLMClient } from "../llm/provider.js";
import {
ShortFictionOutlineAgent,
ShortFictionOutlineReviserAgent,
ShortFictionDraftReviserAgent,
parseShortFictionBatchDraft,
validateShortFictionDraftForFinal,
@@ -33,6 +35,27 @@ function fakeClient(): LLMClient {
}
describe("public short-fiction chain", () => {
it("gives outline generation enough output budget for models with a separate reasoning channel", async () => {
const validOutline = `=== SHORT_FICTION_PLAN_TITLE ===\n电梯多一层\n=== SHORT_FICTION_PLAN ===\n## 12章完整方案`;
const createChat = vi
.spyOn(ShortFictionOutlineAgent.prototype as never, "chat" as never)
.mockResolvedValue({ content: validOutline, usage: ZERO_USAGE });
const reviseChat = vi
.spyOn(ShortFictionOutlineReviserAgent.prototype as never, "chat" as never)
.mockResolvedValue({ content: validOutline, usage: ZERO_USAGE });
const context = { client: fakeClient(), model: "fake", projectRoot: "/tmp" };
const first = await new ShortFictionOutlineAgent(context).createOutline({
direction: "现实悬疑", chapterCount: 12, charsPerChapter: 1000,
});
await new ShortFictionOutlineReviserAgent(context).reviseOutline({
direction: "现实悬疑", outline: first, review: "加强反扑", chapterCount: 12, charsPerChapter: 1000,
});
expect(createChat.mock.calls[0]?.[1]).toMatchObject({ maxTokens: 16_384 });
expect(reviseChat.mock.calls[0]?.[1]).toMatchObject({ maxTokens: 16_384 });
});
it("parses a complete tagged short-fiction draft", () => {
const draft = parseShortFictionBatchDraft(`
=== SHORT_FICTION_TITLE ===
@@ -112,6 +112,59 @@ describe("short fiction resume + failure marker (C2)", () => {
expect(status.error).toContain("503");
});
it("keeps the complete first outline when the optional outline revision fails", async () => {
const firstOutline = { storyTitle: "电梯多一层", rawContent: "# 电梯多一层\n\n## 12章方案\n完整第一版方案" };
vi.spyOn(ShortFictionOutlineAgent.prototype, "createOutline").mockResolvedValue(firstOutline);
vi.spyOn(ShortFictionOutlineReviewerAgent.prototype, "reviewOutline").mockResolvedValue("第六章需要加强反扑");
vi.spyOn(ShortFictionOutlineReviserAgent.prototype, "reviseOutline")
.mockRejectedValue(new Error("model reached the output limit (length)"));
const complete = parseShortFictionBatchDraft(DRAFT_MD, { expectedChapters: CH });
const writeDraft = vi.spyOn(ShortFictionWriterAgent.prototype, "writeDraft").mockResolvedValue(complete);
vi.spyOn(ShortFictionDraftReviewerAgent.prototype, "reviewDraft").mockResolvedValue("looks fine");
vi.spyOn(ShortFictionDraftReviserAgent.prototype, "reviseDraft").mockResolvedValue(complete);
vi.spyOn(ShortFictionPackagingAgent.prototype, "generatePackage").mockResolvedValue({
title: "电梯多一层", intro: "钩子", sellingPoints: ["反转"], coverPrompt: "", rawContent: "",
});
const result = await runShortFictionProduction({
projectRoot: root, direction: "恐怖短篇", chapterCount: CH,
charsPerChapter: 1000, cover: false, runtimes: runtimes(root),
});
expect(writeDraft).toHaveBeenCalledWith(expect.objectContaining({ outlineMarkdown: firstOutline.rawContent }));
expect((await readFile(join(root, result.outlinePath), "utf-8")).trim()).toBe(firstOutline.rawContent);
expect(await readFile(join(root, "shorts", result.storyId, "reviews", "outline-v002-warning.md"), "utf-8"))
.toContain("model reached the output limit");
const status = JSON.parse(await readFile(join(root, "shorts", result.storyId, "status.json"), "utf-8"));
expect(status).toMatchObject({ status: "complete" });
expect(status.warning).toContain("outline revision skipped");
});
it("uses the confirmed title as project identity instead of a malformed generated heading", async () => {
const malformedOutline = {
storyTitle: "one-line-platform-title",
rawContent: "# One line platform title\n\n## 12章方案\n完整方案",
};
vi.spyOn(ShortFictionOutlineAgent.prototype, "createOutline").mockResolvedValue(malformedOutline);
vi.spyOn(ShortFictionOutlineReviewerAgent.prototype, "reviewOutline").mockResolvedValue("可执行");
vi.spyOn(ShortFictionOutlineReviserAgent.prototype, "reviseOutline").mockResolvedValue(malformedOutline);
stubDownstream();
const result = await runShortFictionProduction({
projectRoot: root,
title: "《没有录音的承认》",
direction: "现实婚姻悬疑",
chapterCount: CH,
charsPerChapter: 1000,
cover: false,
runtimes: runtimes(root),
});
expect(result.storyId).toBe("没有录音的承认");
await expect(access(join(root, "shorts", "没有录音的承认", "final", "full.md"))).resolves.toBeUndefined();
await expect(access(join(root, "shorts", "one-line-platform-title"))).rejects.toThrow();
});
it("continues a truncated first draft before review instead of reviewing empty chapters", async () => {
await mkdir(join(root, "shorts", "elevator", "outline"), { recursive: true });
await writeFile(join(root, "shorts", "elevator", "outline", "v002.md"), "## 既有大纲", "utf-8");
@@ -434,7 +434,7 @@ describe("applyRuntimeStateDelta", () => {
]);
});
it("merges duplicate restated hook families into the matched active hook", () => {
it("does not infer semantic identity between different hook ids", () => {
const result = applyRuntimeStateDelta({
snapshot: {
manifest: {
@@ -487,10 +487,10 @@ describe("applyRuntimeStateDelta", () => {
}),
});
expect(result.hooks.hooks).toHaveLength(1);
expect(result.hooks.hooks[0]).toEqual(expect.objectContaining({
hookId: "anonymous-source-scope",
lastAdvancedChapter: 12,
}));
expect(result.hooks.hooks).toHaveLength(2);
expect(result.hooks.hooks.map((hook) => hook.hookId)).toEqual([
"anonymous-source-scope",
"anonymous-source-restated",
]);
});
});
@@ -1,13 +1,20 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { runWorkerAgent } from "../agent/worker-agent.js";
import { Type } from "@sinclair/typebox";
import { runWorkerAgent, runWorkerAgentTool } from "../agent/worker-agent.js";
import { BaseAgent, type AgentContext } from "../agents/base.js";
const chatCompletionMock = vi.hoisted(() => vi.fn());
const guardedPiStreamMock = vi.hoisted(() => vi.fn());
vi.mock("../llm/provider.js", () => ({
chatCompletion: chatCompletionMock,
}));
vi.mock("../agent/pi-stream.js", async () => {
const actual = await vi.importActual<typeof import("../agent/pi-stream.js")>("../agent/pi-stream.js");
return { ...actual, guardedPiStream: guardedPiStreamMock };
});
function client(): AgentContext["client"] {
return {
provider: "openai",
@@ -47,6 +54,7 @@ class TwoStepWorker extends BaseAgent {
describe("Pi worker harness", () => {
beforeEach(() => {
chatCompletionMock.mockReset();
guardedPiStreamMock.mockReset();
});
afterEach(() => {
@@ -127,4 +135,54 @@ describe("Pi worker harness", () => {
await expect(running).rejects.toThrow("user stopped");
expect(chatCompletionMock).toHaveBeenCalledTimes(1);
});
it("submits host-consumed state through one typed Pi tool call", async () => {
const { createAssistantMessageEventStream } = await import("@mariozechner/pi-ai");
guardedPiStreamMock.mockImplementation((model: AgentContext["client"]["_piModel"]) => {
const stream = createAssistantMessageEventStream();
const message = {
role: "assistant" as const,
content: [{
type: "toolCall" as const,
id: "state-1",
name: "submit_state",
arguments: { label: "母亲", status: "等待退烧药" },
}],
api: model?.api ?? "openai-completions",
provider: model?.provider ?? "openai",
model: model?.id ?? "deepseek-v4-flash",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse" as const,
timestamp: Date.now(),
};
stream.push({ type: "done", reason: "toolUse", message });
stream.end(message);
return stream;
});
const result = await runWorkerAgentTool(
client(),
"deepseek-v4-flash",
[{ role: "user", content: "登记当前角色状态" }],
{
name: "submit_state",
label: "提交状态",
description: "提交角色状态。",
parameters: Type.Object({
label: Type.String(),
status: Type.String(),
}),
},
);
expect(result).toEqual({ label: "母亲", status: "等待退烧药" });
expect(guardedPiStreamMock).toHaveBeenCalledTimes(1);
});
});
+13 -9
View File
@@ -757,7 +757,7 @@ describe("WriterAgent", () => {
}
});
it("returns the arbiter-resolved delta instead of raw new-hook candidates", async () => {
it("preserves the settler's explicit existing hook identity", async () => {
const root = await mkdtemp(join(tmpdir(), "inkos-writer-arbiter-test-"));
const bookDir = join(root, "book");
const storyDir = join(bookDir, "story");
@@ -840,18 +840,22 @@ describe("WriterAgent", () => {
JSON.stringify({
chapter: 3,
hookOps: {
upsert: [],
upsert: [
{
hookId: "anonymous-source-scope",
startChapter: 1,
type: "source-risk",
status: "progressing",
lastAdvancedChapter: 3,
expectedPayoff: "Reveal how much the anonymous source already knew about the route and address.",
notes: "This chapter adds the address angle to the anonymous source question.",
},
],
mention: [],
resolve: [],
defer: [],
},
newHookCandidates: [
{
type: "source-risk",
expectedPayoff: "Reveal how much the anonymous source already knew about the route and address.",
notes: "This chapter adds the address angle to the anonymous source question.",
},
],
newHookCandidates: [],
chapterSummary: {
chapter: 3,
title: "Address Leak",
+68 -47
View File
@@ -1,7 +1,7 @@
import { createHash, randomUUID } from "node:crypto";
import { Agent } from "@mariozechner/pi-agent-core";
import type { AgentEvent, AgentMessage } from "@mariozechner/pi-agent-core";
import { streamSimple, getModel, getEnvApiKey, createAssistantMessageEventStream } from "@mariozechner/pi-ai";
import { getModel, getEnvApiKey, createAssistantMessageEventStream } from "@mariozechner/pi-ai";
import type {
Model,
Api,
@@ -15,11 +15,11 @@ import type {
UserMessage,
} from "@mariozechner/pi-ai";
import type { PipelineRunner } from "../pipeline/runner.js";
import { assertWithinContextWindow, estimatePiContextTokens, guardAssistantMessageStream } from "../llm/provider.js";
import { buildAgentSystemPrompt } from "./agent-system-prompt.js";
import {
createPatchChapterTextTool,
createReplaceChapterTextTool,
createResyncChapterStateTool,
createDeleteLatestChapterTool,
createRenameEntityTool,
createSubAgentTool,
@@ -38,6 +38,10 @@ import {
createStoryboardCreationTool,
createInteractiveFilmCreationTool,
createTranslationCreateTool,
createFanficBookTool,
createContinuationImportTool,
createSpinoffBookTool,
createImitationBookTool,
createResearchWebTool,
createIngestMaterialTool,
createRetrieveMaterialTool,
@@ -50,7 +54,7 @@ import {
createNarrativeForecastGetTool,
createNarrativeForecastSelectTool,
} from "./forecast-tools.js";
import { createBookContextTransform } from "./context-transform.js";
import { createBookContextTransform, createInteractiveFilmContextTransform } from "./context-transform.js";
import {
appendTranscriptEvents,
readTranscriptEvents,
@@ -81,12 +85,8 @@ import {
sanitizeSkillTurnMessage,
type ActivatedSkillGuidance,
} from "./skill-tool.js";
import {
agentTrajectoryHeaders,
beginAgentModelCall,
opaqueConversationId,
runWithAgentTrajectory,
} from "../llm/agent-trajectory.js";
import { opaqueConversationId, runWithAgentTrajectory } from "../llm/agent-trajectory.js";
import { guardedPiStream } from "./pi-stream.js";
// ---------------------------------------------------------------------------
// Types
@@ -191,6 +191,7 @@ interface CachedAgent {
allowSystemFileRead: boolean;
backgroundTaskContext: string | undefined;
suppressProductionTools: boolean;
currentAttachmentPaths: string[];
lastCommittedSeq: number;
lastActive: number;
}
@@ -357,37 +358,6 @@ function attachmentImages(attachments: ReadonlyArray<AgentSessionAttachment> | u
}));
}
function guardedStreamSimple<TApi extends Api>(
model: Model<TApi>,
context: PiContext,
options?: SimpleStreamOptions,
): AssistantMessageEventStream {
const reservedOutputTokens = Number.isFinite(options?.maxTokens)
? options!.maxTokens!
: Number.isFinite(model.maxTokens)
? model.maxTokens
: 4096;
assertWithinContextWindow({
piModel: model,
model: model.id,
estimatedInputTokens: estimatePiContextTokens(context),
reservedOutputTokens,
});
const modelCall = beginAgentModelCall();
const traceHeaders = agentTrajectoryHeaders(model.baseUrl, modelCall, 1, {
effort: String(options?.reasoning ?? (model.reasoning ? "enabled" : "disabled")),
});
return guardAssistantMessageStream(
model,
(signal) => streamSimple(model, context, {
...options,
headers: { ...(options?.headers ?? {}), ...traceHeaders },
signal,
}),
options?.signal,
);
}
function localAssistantStopStream(model: Model<Api>): AssistantMessageEventStream {
const stream = createAssistantMessageEventStream();
const message: AssistantMessage = {
@@ -410,10 +380,16 @@ function localAssistantStopStream(model: Model<Api>): AssistantMessageEventStrea
export function isTerminalProductionToolName(toolName: unknown): boolean {
return toolName === "propose_action"
|| toolName === "sub_agent"
|| toolName === "resync_chapter_state"
|| toolName === "short_fiction_run"
|| toolName === "script_create"
|| toolName === "storyboard_create"
|| toolName === "interactive_film_create"
|| toolName === "translation_create"
|| toolName === "fanfic_create"
|| toolName === "continuation_import"
|| toolName === "spinoff_create"
|| toolName === "imitation_create"
|| toolName === "generate_cover"
|| toolName === "play_start"
|| toolName === "play_edit"
@@ -438,8 +414,7 @@ function hasUnansweredTerminalToolResult(messages: AgentMessage[]): boolean {
}
if (role !== "toolResult") continue;
const toolName = (message as { toolName?: unknown }).toolName;
const isError = (message as { isError?: unknown }).isError;
if (isTerminalProductionToolName(toolName) && isError !== true) {
if (isTerminalProductionToolName(toolName)) {
return !assistantTextAfterTool;
}
}
@@ -784,8 +759,13 @@ const PRODUCTION_MUTATION_TOOL_NAMES = new Set([
"rename_entity",
"patch_chapter_text",
"replace_chapter_text",
"resync_chapter_state",
"delete_latest_chapter",
"import_chapters",
"fanfic_create",
"continuation_import",
"spinoff_create",
"imitation_create",
]);
type CreateAgentToolsForModeParams = {
@@ -803,6 +783,7 @@ type CreateAgentToolsForModeParams = {
readonly playWorldExists: boolean;
readonly intentSkillTool?: ReturnType<typeof createUseSkillTool>;
readonly requestedSkillIds?: () => ReadonlyArray<string>;
readonly attachmentPaths?: () => ReadonlyArray<string>;
readonly activeSkills?: () => ReadonlyArray<ActivatedSkillGuidance>;
readonly workerSkills?: (agent: string) => ReadonlyArray<ActivatedSkillGuidance>;
readonly productionSkills?: (capability: ProductionSkillCapability) => ReadonlyArray<ActivatedSkillGuidance>;
@@ -824,10 +805,12 @@ function createModeTools(params: CreateAgentToolsForModeParams) {
const proposalTool = createProposeActionTool(lang, {
sameSession: params.sessionKind !== "chat",
requestedSkillIds: params.requestedSkillIds,
attachmentPaths: params.attachmentPaths,
});
const researchTool = createResearchWebTool(params.projectRoot);
const materialTool = createIngestMaterialTool(params.projectRoot);
const materialRetrievalTool = createRetrieveMaterialTool(params.projectRoot);
const projectReadTool = createReadTool(params.projectRoot, { scope: "project" });
const importChaptersTool = createImportChaptersTool(params.pipeline, params.bookId, params.projectRoot);
const isConfirmed = (
intent: NonNullable<AgentSessionConfig["requestedIntent"]>,
@@ -840,6 +823,30 @@ function createModeTools(params: CreateAgentToolsForModeParams) {
if (isConfirmed("translation_create")) {
return [createTranslationCreateTool(params.projectRoot, { actionPayload: params.actionPayload })];
}
if (isConfirmed("fanfic_init")) {
return [createFanficBookTool(params.pipeline, params.projectRoot, {
defaultSkills: params.productionSkills?.("longWriting"),
activeSkills: params.activeSkills,
})];
}
if (isConfirmed("continuation_import")) {
return [createContinuationImportTool(params.pipeline, params.bookId, params.projectRoot, {
defaultSkills: params.productionSkills?.("longWriting"),
activeSkills: params.activeSkills,
})];
}
if (isConfirmed("spinoff_create")) {
return [createSpinoffBookTool(params.pipeline, params.projectRoot, {
defaultSkills: params.productionSkills?.("longWriting"),
activeSkills: params.activeSkills,
})];
}
if (isConfirmed("style_imitation")) {
return [createImitationBookTool(params.pipeline, params.projectRoot, {
defaultSkills: params.productionSkills?.("longWriting"),
activeSkills: params.activeSkills,
})];
}
return [proposalTool, researchTool, materialTool, materialRetrievalTool, importChaptersTool];
}
@@ -867,7 +874,7 @@ function createModeTools(params: CreateAgentToolsForModeParams) {
activeSkills: params.activeSkills,
})];
}
return [proposalTool, materialTool, materialRetrievalTool];
return [proposalTool, projectReadTool, materialTool, materialRetrievalTool];
}
if (params.sessionKind === "storyboard") {
@@ -879,7 +886,7 @@ function createModeTools(params: CreateAgentToolsForModeParams) {
activeSkills: params.activeSkills,
})];
}
return [proposalTool, materialTool, materialRetrievalTool];
return [proposalTool, projectReadTool, materialTool, materialRetrievalTool];
}
if (params.sessionKind === "interactive-film") {
@@ -891,7 +898,7 @@ function createModeTools(params: CreateAgentToolsForModeParams) {
activeSkills: params.activeSkills,
})];
}
return [proposalTool, materialTool, materialRetrievalTool];
return [proposalTool, projectReadTool, materialTool, materialRetrievalTool];
}
if (params.sessionKind === "interactive-film-authoring") {
@@ -970,6 +977,11 @@ function createModeTools(params: CreateAgentToolsForModeParams) {
createRenameEntityTool(params.pipeline, params.projectRoot, params.bookId),
createPatchChapterTextTool(params.pipeline, params.projectRoot, params.bookId),
createReplaceChapterTextTool(params.pipeline, params.projectRoot, params.bookId),
createResyncChapterStateTool(params.pipeline, params.bookId, {
language: lang,
defaultSkills: params.productionSkills?.("longWriting"),
activeSkills: params.activeSkills,
}),
createDeleteLatestChapterTool(params.projectRoot, params.bookId),
researchTool,
materialTool,
@@ -1163,6 +1175,7 @@ async function runAgentSessionUnlocked(
playWorldExists,
intentSkillTool,
requestedSkillIds: () => [...turnSkills.keys()],
attachmentPaths: () => cached?.currentAttachmentPaths ?? [],
activeSkills: () => [...turnSkills.values()],
workerSkills: (agent) => {
if (agent === "architect" || agent === "writer") return productionSkills("longWriting");
@@ -1182,7 +1195,9 @@ async function runAgentSessionUnlocked(
: agentTools,
messages: initialAgentMessages,
},
transformContext: createBookContextTransform(bookId, projectRoot, { onContextCompression }),
transformContext: sessionKind === "interactive-film-authoring" && bookId
? createInteractiveFilmContextTransform(bookId, projectRoot)
: createBookContextTransform(bookId, projectRoot, { onContextCompression }),
convertToLlm: (messages) => {
terminalToolResultTail = hasUnansweredTerminalToolResult(messages);
return convertAgentMessagesForModel(messages, model);
@@ -1193,7 +1208,7 @@ async function runAgentSessionUnlocked(
return localAssistantStopStream(streamModel);
}
if (isLlmStubEnabled()) return stubAgentStream(streamModel, context);
return guardedStreamSimple(streamModel, context, options);
return guardedPiStream(streamModel, context, options);
},
getApiKey: (provider: string) => {
if (config.apiKey) return config.apiKey;
@@ -1219,6 +1234,9 @@ async function runAgentSessionUnlocked(
allowSystemFileRead,
backgroundTaskContext: config.backgroundTaskContext,
suppressProductionTools,
currentAttachmentPaths: (config.attachments ?? [])
.map((attachment) => attachment.storedPath?.trim())
.filter((path): path is string => Boolean(path)),
lastCommittedSeq: currentCommittedSeq ?? await latestCommittedSeq(projectRoot, sessionId),
lastActive: Date.now(),
};
@@ -1227,6 +1245,9 @@ async function runAgentSessionUnlocked(
}
cached.lastActive = Date.now();
cached.currentAttachmentPaths = (config.attachments ?? [])
.map((attachment) => attachment.storedPath?.trim())
.filter((path): path is string => Boolean(path));
cached.turnSkills.clear();
for (const skill of skillResolution.usedSkills) {
cached.turnSkills.set(skill.id, { skill, resources: [] });
+79 -24
View File
@@ -38,14 +38,13 @@ function buildChatPrompt(isZh: boolean): string {
propose_actionresearch_webingest_materialretrieve_materialimport_chapters////仿 propose_action// research_web URL PDF/Markdown// ingest_material retrieve_material
propose_actionresearch_webingest_materialretrieve_materialimport_chapters////仿 propose_action// research_web URL PDF/Markdown// ingest_material retrieve_material
稿InkOS import_chapters ingest_materialimport_chapters bookId/ stored_path
create_bookshort_runplay_startgenerate_coverscript_createstoryboard_createinteractive_film_createtranslation_create session
fanfic_initcontinuation_importspinoff_createstyle_imitation Studio
/ / / 仿 / / / 仿 / 仿 propose_actioninstruction =fanfic_init=continuation_import//线=spinoff_create仿///仿=style_imitation/ /
create_bookshort_runplay_startgenerate_coverscript_createstoryboard_createinteractive_film_createtranslation_createfanfic_initcontinuation_importspinoff_createstyle_imitation
=fanfic_init=continuation_import InkOS 线=spinoff_create=style_imitation仿
propose_action instruction // session createBook / shortRun / playStart / generateCover / scriptCreate / storyboardCreate / interactiveFilmCreate / translationCreate instruction / translationCreate.filePathsourceLanguagetargetLanguage西 zh/en/ja filePath stored_path//playStart.mode open//playStart.mode guided///使 interactive_film_create play_start
propose_action instruction // session createBook / shortRun / playStart / generateCover / scriptCreate / storyboardCreate / interactiveFilmCreate / translationCreate / fanficCreate / continuationImport / spinoffCreate / imitationCreate instruction 仿使 stored_path continuationImport.sourcePath bookId title parentBookId/ translationCreate.filePathsourceLanguagetargetLanguage西 zh/en/ja filePath stored_path//playStart.mode open//playStart.mode guided///使 interactive_film_create play_start
chat /research_webingest_material retrieve_material import_chapters
${commonOutputRules(true)}`
@@ -53,14 +52,13 @@ ${commonOutputRules(true)}`
This is not an automatic production surface. Answer questions, discussion, comparisons, and issue reports directly.
Available tools: propose_action, research_web, ingest_material, retrieve_material, and import_chapters. Use propose_action when the user clearly wants to create a book, run short fiction, start a play world, generate a cover, create a script, create a storyboard, create a translation/localization project, or open assisted fanfiction / continuation / side-story / style-imitation workflows. Use research_web when the user explicitly asks for web research, fact checking, era/profession/worldbuilding references, or market research. Use ingest_material when the user provides a URL, uploaded PDF/Markdown/text file, or asks to archive/read provided materials. Use retrieve_material before answering, comparing, or continuing from archived materials. Research reports and material cards are reference material only and do not automatically change canon or prose.
Available tools: propose_action, research_web, ingest_material, retrieve_material, and import_chapters. Use propose_action when the user clearly wants to create a book, run short fiction, start a play world, generate a cover, create a script, create a storyboard, create a translation/localization project, or create fanfiction / continuation / side-story / style-imitation work. Use research_web when the user explicitly asks for web research, fact checking, era/profession/worldbuilding references, or market research. Use ingest_material when the user provides a URL, uploaded PDF/Markdown/text file, or asks to archive/read provided materials. Use retrieve_material before answering, comparing, or continuing from archived materials. Research reports and material cards are reference material only and do not automatically change canon or prose.
Use import_chapters when the user wants existing novel chapters or a full manuscript imported into a book as real chapters (InkOS reverse-engineers the truth files from the text); use ingest_material when they only want reference material archived do not confuse the two. import_chapters requires an explicit target bookId (an existing book; if none exists, create the book first) and a local file/directory path: the stored_path from the Uploaded Files block works, and so does an absolute path the user names on this machine.
Production actions: create_book, short_run, play_start, generate_cover, script_create, storyboard_create, interactive_film_create, translation_create. After confirmation, InkOS switches to the matching session or creates the project and runs them.
Assisted workflow actions: fanfic_init, continuation_import, spinoff_create, style_imitation. After confirmation, InkOS only opens the existing Studio tool; do not claim finished content was generated.
Assisted workflows open a tool and prepare materials; they do not immediately generate finished content. When the user explicitly asks for fanfiction, continuation, side-story/spinoff, style imitation, style analysis, reference-style analysis, prose mimicry, or "analyze first then imitate", you must call propose_action. Do not answer by asking for a title/source text/parent-book path or by explaining the workflow in plain text. If materials are missing, infer a short temporary title from the user's direction, and say in the instruction that the user will fill missing materials in the opened tool. Mapping: fanfiction=fanfic_init, continuation=continuation_import, side-story/spinoff/canon-materials=spinoff_create, style imitation/style analysis/reference-style/prose mimicry=style_imitation. The confirmation card title/summary must say "open workflow / prepare materials"; do not say finished content will be generated.
Production actions: create_book, short_run, play_start, generate_cover, script_create, storyboard_create, interactive_film_create, translation_create, fanfic_init, continuation_import, spinoff_create, style_imitation. After confirmation, InkOS runs the request directly instead of making the user repeat it in another form.
Mapping: fanfiction creation=fanfic_init; importing an existing novel for continuation=continuation_import; a side story that inherits an existing InkOS book's canon without advancing its mainline=spinoff_create; an original story that learns prose style from a reference=style_imitation. Answer pure style-analysis questions directly rather than hijacking them into production. If real source material, parent book, or original story direction is missing, ask one key question; never fabricate a path or canon.
When calling propose_action, instruction must be self-contained: include target surface, title/book/path, story or visual direction, and concrete context behind references like "that book" or "this cover". Do not make the next session infer missing context from this chat. Put known execution arguments into the structured createBook / shortRun / playStart / generateCover / scriptCreate / storyboardCreate / interactiveFilmCreate / translationCreate fields as well; do not leave them only in instruction text. Translation/localization projects must fill translationCreate.filePath, sourceLanguage, and targetLanguage; language fields should be human-readable names such as "Auto detect", "Chinese (Simplified)", "English", "Japanese", or "Brazilian Portuguese" instead of requiring ISO abbreviations like zh/en/ja; when the user says "translate this attachment", use stored_path from the uploaded-files block. For interactive worlds, set playStart.mode=open when the user asks for open/free-form play, and playStart.mode=guided when the user asks for branching/choice-led play. For interactive film/drama/game-script deliverables with branch logic, flags, endings, scripts, and storyboards, use interactive_film_create instead of play_start.
When calling propose_action, instruction must be self-contained: include title/book/path, story or visual direction, and concrete context behind references like "that book" or "this cover". Do not make the next session infer missing context from the previous conversation. Put known execution arguments into the structured createBook / shortRun / playStart / generateCover / scriptCreate / storyboardCreate / interactiveFilmCreate / translationCreate / fanficCreate / continuationImport / spinoffCreate / imitationCreate fields as well; do not leave them only in instruction text. Fanfiction and imitation should use stored_path from uploaded files when possible; continuation must fill continuationImport.sourcePath plus an existing bookId or a new title; side stories must name a real parentBookId. Translation/localization projects must fill translationCreate.filePath, sourceLanguage, and targetLanguage; language fields should be human-readable names such as "Auto detect", "Chinese (Simplified)", "English", "Japanese", or "Brazilian Portuguese" instead of requiring ISO abbreviations like zh/en/ja; when the user says "translate this attachment", use stored_path from the uploaded-files block. For interactive worlds, set playStart.mode=open when the user asks for open/free-form play, and playStart.mode=guided when the user asks for branching/choice-led play. For interactive film/drama/game-script deliverables with branch logic, flags, endings, scripts, and storyboards, use interactive_film_create instead of play_start.
If information is missing, ask one key question. Do not create, write, edit, or generate story/image artifacts in chat; research_web, ingest_material, and retrieve_material are reference-material-only exceptions, and import_chapters is the only exception that writes book chapters call it only when the user explicitly asks to import existing chapters.
${commonOutputRules(false)}`;
@@ -234,14 +232,14 @@ ${commonOutputRules(false)}`;
? `你是 InkOS Short 助手。当前入口只负责把独立短篇或短篇封面需求聊清楚,然后让用户确认。
propose_actioningest_materialretrieve_material action=short_run action=generate_cover/ propose_action/propose_action
instruction / shortRundirectionlanguagechapterscharsPerChaptercoverlanguage zh encharsPerChapter zh 900-1200 1000en 600-800 650
instruction / shortRuntitledirectionlanguagechapterscharsPerChaptercovertitle 使宿language zh encharsPerChapter zh 900-1200 1000en 600-800 650
instruction books/
${commonOutputRules(true)}`
: `You are the InkOS Short assistant. This surface clarifies standalone short-fiction or cover requests and asks for confirmation before production.
Available tools: propose_action, ingest_material, retrieve_material. Use action=short_run for full short production; action=generate_cover for cover-only work. Archive/retrieve user-provided references when needed, but do not generate finished content directly. When the core conflict and protagonist pressure are clear, you must call propose_action; do not hand-write the confirmation card as plain text. If the user says "confirm first" or "write after confirmation", propose_action is that confirmation card; still call it instead of summarizing in plain text and waiting for a second confirmation.
instruction must be self-contained: genre direction, title/working title, protagonist pressure, core conflict, emotional payoff, cover direction, or target short path. For full short production, also fill shortRun: direction, language, chapters, charsPerChapter, cover. Set language to the output language the user asked for; it may differ from the conversation language: keep the conversation language (en here) when the user does not name one, and fill zh when the user explicitly asks for a Chinese short. charsPerChapter is per-chapter length, not total story length: 900-1200 Chinese characters (default 1000) for zh, or 600-800 English words (default 650) for en.
instruction must be self-contained: genre direction, title/working title, protagonist pressure, core conflict, emotional payoff, cover direction, or target short path. For full short production, also fill shortRun: title, direction, language, chapters, charsPerChapter, cover. title is required even when it is only a working title because the host uses it as stable project identity rather than guessing from generated prose. Set language to the output language the user asked for; it may differ from the conversation language: keep the conversation language (en here) when the user does not name one, and fill zh when the user explicitly asks for a Chinese short. charsPerChapter is per-chapter length, not total story length: 900-1200 Chinese characters (default 1000) for zh, or 600-800 English words (default 650) for en.
If title or cover direction is missing, invent a working version inside instruction; ask one key question only when genre, protagonist pressure, or core conflict is too vague. Do not create books/ projects, start play worlds, or route short-fiction requests to book creation.
${commonOutputRules(false)}`;
@@ -267,17 +265,17 @@ ${commonOutputRules(false)}`;
return isZh
? `你是 InkOS 剧本创作助手。当前入口负责把小说、创意、大纲或已有文本转成用户可继续修改的剧本。
propose_actioningest_materialretrieve_materialaction=script_create / / / / 广 / /
propose_actionreadingest_materialretrieve_materialaction=script_create / / / / 广 / / InkOS sourcePath read
///
instruction scriptCreatetitlesourceKindtargetFormatsourceText/sourcePathrequirementsepisodeCountepisodeDurationsourceText sourcePath
instruction scriptCreatetitlesourceKindtargetFormatsourceText/sourcePathrequirementsepisodeCountepisodeDurationsourceText 使 sourcePath
//
${commonOutputRules(true)}`
: `You are the InkOS script creation assistant. This surface turns a novel, idea, outline, or existing text into an editable script.
Available tools: propose_action, ingest_material, retrieve_material with action=script_create. When the user asks for a script, vertical short-drama script, novel-to-script adaptation, interactive script, audio drama, or script-before-storyboard work, archive/retrieve references and confirm the spec first; do not write the full script in chat.
Available tools: propose_action, read, ingest_material, retrieve_material with action=script_create. When the user asks for a script, vertical short-drama script, novel-to-script adaptation, interactive script, audio drama, or script-before-storyboard work, archive/retrieve references and confirm the spec first; do not write the full script in chat. When the user names a sourcePath inside the current InkOS project, read it before discussing or proposing; do not ask them to upload or paste it again.
The confirmation card should leave creative room for the user: title/working title, source type, target script format, episode count or duration, what to preserve, what may change, dialogue/scene/production constraints. Do not decide fidelity, commercialization, or low-budget adaptation strength for the user; if unclear, say it remains adjustable or ask one key question.
instruction must be self-contained. Also fill scriptCreate when known: title, sourceKind, targetFormat, sourceText/sourcePath, requirements, episodeCount, episodeDuration. sourceText may contain the user's current material or compact summary; if the source is too long, ask the user to provide it through the entry or sourcePath instead of inventing it.
instruction must be self-contained. Also fill scriptCreate when known: title, sourceKind, targetFormat, sourceText/sourcePath, requirements, episodeCount, episodeDuration. sourceText may contain the user's current material; use sourcePath for long project-local sources and read them first instead of inventing or silently compressing them.
Ask one key question only when title/source/target format are all too vague.
${commonOutputRules(false)}`;
@@ -303,17 +301,17 @@ ${commonOutputRules(false)}`;
return isZh
? `你是 InkOS 分镜创作助手。当前入口负责把剧本、小说片段、创意或场景列表拆成可拍、可画、可继续修改的分镜。
propose_actioningest_materialretrieve_materialaction=storyboard_create / / / / /
propose_actionreadingest_materialretrieve_materialaction=storyboard_create / / / / / InkOS sourcePath read
/
instruction storyboardCreatetitlesourceKindsourceText/sourcePathrequirementsvisualStyleaspectRatiogranularitymaxShotssourceText sourcePath
instruction storyboardCreatetitlesourceKindsourceText/sourcePathrequirementsvisualStyleaspectRatiogranularitymaxShotssourceText 使 sourcePath
//
${commonOutputRules(true)}`
: `You are the InkOS storyboard creation assistant. This surface turns scripts, novel excerpts, ideas, or scene lists into editable storyboard tables and image prompts.
Available tools: propose_action, ingest_material, retrieve_material with action=storyboard_create. When the user asks for storyboard, shot list, storyboard image prompts, script-to-storyboard, or novel-to-storyboard work, archive/retrieve references and confirm the spec first; do not write the full storyboard in chat.
Available tools: propose_action, read, ingest_material, retrieve_material with action=storyboard_create. When the user asks for storyboard, shot list, storyboard image prompts, script-to-storyboard, or novel-to-storyboard work, archive/retrieve references and confirm the spec first; do not write the full storyboard in chat. When the user names a sourcePath inside the current InkOS project, read it before discussing or proposing; do not ask them to upload or paste it again.
The confirmation card should leave creative room for the user: title/working title, source type, shot granularity, aspect ratio, visual style, max shots, whether image prompts are needed, and what must be preserved. Do not lock shooting style, visual style, or shot count unless the user specified them; if unclear, say it remains adjustable or ask one key question.
instruction must be self-contained. Also fill storyboardCreate when known: title, sourceKind, sourceText/sourcePath, requirements, visualStyle, aspectRatio, granularity, maxShots. sourceText may contain the user's current material or compact summary; if the source is too long, ask the user to provide it through the entry or sourcePath instead of inventing it.
instruction must be self-contained. Also fill storyboardCreate when known: title, sourceKind, sourceText/sourcePath, requirements, visualStyle, aspectRatio, granularity, maxShots. sourceText may contain the user's current material; use sourcePath for long project-local sources and read them first instead of inventing or silently compressing them.
Ask one key question only when title/source/target storyboard form are all too vague.
${commonOutputRules(false)}`;
@@ -339,22 +337,74 @@ ${commonOutputRules(false)}`;
return isZh
? `你是 InkOS 互动影游创作助手。当前入口负责把创意、小说、剧本、大纲或投稿需求整理成可制作的互动影游交付稿。
propose_actioningest_materialretrieve_materialaction=interactive_film_create / / / / / /稿
propose_actionreadingest_materialretrieve_materialaction=interactive_film_create / / / / / /稿 InkOS sourcePath read
//// RPG
instruction interactiveFilmCreatetitlesourceKindsourceText/sourcePathrequirementstargetAudienceepisodeCountepisodeDurationbudgetreferenceModesourceText sourcePath
instruction interactiveFilmCreatetitlesourceKindsourceText/sourcePathrequirementstargetAudienceepisodeCountepisodeDurationbudgetreferenceModesourceText 使 sourcePath
//
${commonOutputRules(true)}`
: `You are the InkOS interactive-film creation assistant. This surface turns ideas, novels, scripts, outlines, or submission requirements into editable interactive film/game-script deliverables.
Available tools: propose_action, ingest_material, retrieve_material with action=interactive_film_create. When the user asks for interactive film, interactive drama, branching narrative game, multi-ending script, or choice-led film/game deliverables, archive/retrieve references and confirm the spec first; do not write the full package in chat.
Available tools: propose_action, read, ingest_material, retrieve_material with action=interactive_film_create. When the user asks for interactive film, interactive drama, branching narrative game, multi-ending script, or choice-led film/game deliverables, archive/retrieve references and confirm the spec first; do not write the full package in chat. When the user names a sourcePath inside the current InkOS project, read it before discussing or proposing; do not ask them to upload or paste it again.
The confirmation card should leave creative room for the user: title/working title, source type, branching structure, endings, variables/flags, target audience, budget, episode/segment count, visual/storyboard needs. Do not default to RPG stats, combat formulas, equipment systems, or a fixed game template unless the user explicitly asks.
instruction must be self-contained. Also fill interactiveFilmCreate when known: title, sourceKind, sourceText/sourcePath, requirements, targetAudience, episodeCount, episodeDuration, budget, referenceMode. sourceText may contain the user's current material; if the source is too long, ask for sourcePath instead of inventing it.
instruction must be self-contained. Also fill interactiveFilmCreate when known: title, sourceKind, sourceText/sourcePath, requirements, targetAudience, episodeCount, episodeDuration, budget, referenceMode. sourceText may contain the user's current material; use sourcePath for long project-local sources and read them first instead of inventing or silently compressing them.
Ask one key question only when title/source/interactive goal are all too vague.
${commonOutputRules(false)}`;
}
function buildInteractiveFilmAuthoringPrompt(projectId: string, isZh: boolean): string {
return isZh
? `你是 InkOS 互动影游创作向导,当前项目是「${projectId}」。
id
##
- set_world_anchor
- upsert_characters
- add_variable
- define_ending
- fill_node
- revise_node使 node id
- generate_node_image
- propose_action draft_structureconnect_choiceremove_node
##
-
-
- generate_node_image
- node id
- Play
${commonOutputRules(true)}`
: `You are the InkOS interactive-film authoring guide for project "${projectId}".
The complete current story graph is injected from disk on every turn. It is the sole authority for node ids, choice ids, variables, conditions, effects, and endings.
## Available tools
- set_world_anchor: edit story core, theme, genre, duration, or world rules.
- upsert_characters: add or update character cards.
- add_variable: add a discrete variable or flag.
- define_ending: add or update an ending.
- fill_node: fill an empty node with a complete scene, dialogue, choices, and image direction.
- revise_node: rewrite an existing node from user feedback using its real graph node id.
- generate_node_image: generate and attach an image when the user explicitly requests one.
- propose_action: confirmation only for the high-impact draft_structure, connect_choice, and remove_node actions.
## Boundaries
- Answer discussion and comparison requests directly without tools.
- For explicit character, world, variable, ending, or node edits, call the matching tool instead of merely claiming completion.
- For an explicit node-image request, call generate_node_image; do not return only a prompt.
- Ask one necessary question only when the target is unclear. When it is clear, locate the real node id in the injected graph instead of asking the user to provide it.
- Completion derives only from a successful tool result. Do not create books, shorts, Play worlds, or a new interactive-film project.
${commonOutputRules(false)}`;
}
function buildPlayPrompt(isZh: boolean, confirmedStart: boolean, playWorldExists: boolean): string {
if (confirmedStart) {
return isZh
@@ -521,6 +571,8 @@ function buildBookPrompt(bookId: string, isZh: boolean): string {
- writer
- sub_agent
- patch稿 replace reviser
- //稿 resync_chapter_state reviser
- resync_chapter_state allowNewHooks=false
-
-
-
@@ -541,6 +593,8 @@ ${commonOutputRules(true)}`
- Start writer once for a multi-chapter request and pass the count; never repeat or parallelize it.
- Chapter production must be persisted. Do not emit chapter prose in chat as if it were saved. End the turn after sub_agent succeeds, and derive completion only from a successful tool result.
- Use a local patch only when the user supplies an exact old/new edit, and whole replacement only when the user supplies the complete replacement. Model-generated whole-chapter changes must use reviser.
- When the user explicitly wants the latest chapter prose preserved and only asks to rebuild state, summaries, hooks, or re-audit it, use resync_chapter_state instead of reviser.
- If the user also requires stable hook IDs to be preserved and forbids replacement or new hooks, call resync_chapter_state with allowNewHooks=false.
- Read the authoritative file before changing canon or a role card, preserve everything outside the requested change, and never edit canon through chapter tools.
- Research reports, material cards, and retrieved passages are references, not canon. Write them into canon only after explicit user authorization, and preserve the user's stated purpose when binding a reference.
- If the target chapter, object, or essential material is missing, ask one necessary question.
@@ -575,6 +629,7 @@ export function buildAgentSystemPrompt(
if (sessionKind === "script") return withSkills(buildScriptPrompt(isZh, isConfirmedAction(options, "script_create")));
if (sessionKind === "storyboard") return withSkills(buildStoryboardPrompt(isZh, isConfirmedAction(options, "storyboard_create")));
if (sessionKind === "interactive-film") return withSkills(buildInteractiveFilmPrompt(isZh, isConfirmedAction(options, "interactive_film_create")));
if (sessionKind === "interactive-film-authoring" && bookId) return withSkills(buildInteractiveFilmAuthoringPrompt(bookId, isZh));
if (sessionKind === "edit") return withSkills(buildEditPrompt(bookId, isZh));
if (sessionKind === "book" && bookId) return withSkills(buildBookPrompt(bookId, isZh));
return withSkills(buildChatPrompt(isZh));
+636 -69
View File
@@ -6,14 +6,19 @@ import { type ReviseMode } from "../agents/reviser.js";
import { defaultChapterLength } from "../utils/length-metrics.js";
import { inferLanguage } from "../utils/language.js";
import { mkdir, readFile, writeFile, readdir, stat } from "node:fs/promises";
import { isAbsolute, join, resolve } from "node:path";
import { basename, isAbsolute, join, resolve } from "node:path";
import { StateManager } from "../state/manager.js";
import { deleteLatestChapter } from "../state/chapter-delete.js";
import { assertSafeTruthFileName, createInteractionToolsFromDeps } from "../interaction/project-tools.js";
import { writeExportArtifact } from "../interaction/export-artifact.js";
import { assertSafeBookId, deriveBookIdFromTitle } from "../utils/book-id.js";
import { safeChildPath } from "../utils/path-safety.js";
import { normalizePlatformId, normalizePlatformOrOther } from "../models/book.js";
import {
normalizePlatformId,
normalizePlatformOrOther,
type BookConfig,
type FanficMode,
} from "../models/book.js";
import { generateShortFictionCover, runShortFictionProduction } from "../pipeline/short-fiction-runner.js";
import { runInteractiveFilmCreation, runScriptCreation, runStoryboardCreation } from "../pipeline/script-storyboard-runner.js";
import { createTranslationProjectFromFile } from "../translation/index.js";
@@ -83,6 +88,80 @@ function resolveToolBookId(
return safeBookId;
}
function buildAgentBookConfig(input: {
readonly title: string;
readonly genre?: string;
readonly platform?: string;
readonly language?: "zh" | "en";
readonly targetChapters?: number;
readonly chapterWordCount?: number;
readonly parentBookId?: string;
readonly fanficMode?: FanficMode;
}, defaults: { readonly targetChapters?: number; readonly chapterWordCount?: number } = {}): BookConfig {
const now = new Date().toISOString();
const id = deriveBookIdFromTitle(input.title);
if (!id) throw new Error(`Could not derive a valid book id from title: ${JSON.stringify(input.title)}`);
return {
id,
title: input.title.trim(),
platform: normalizePlatformOrOther(input.platform),
genre: input.genre?.trim() || "other",
status: "outlining",
targetChapters: input.targetChapters ?? defaults.targetChapters ?? 200,
chapterWordCount: input.chapterWordCount
?? defaults.chapterWordCount
?? defaultChapterLength(input.language === "en" ? "en" : "zh"),
...(input.language ? { language: input.language } : {}),
...(input.parentBookId ? { parentBookId: input.parentBookId } : {}),
...(input.fanficMode ? { fanficMode: input.fanficMode } : {}),
createdAt: now,
updatedAt: now,
};
}
async function assertBookDoesNotExist(projectRoot: string, bookId: string): Promise<void> {
try {
await stat(new StateManager(projectRoot).bookDir(bookId));
throw new Error(`Book "${bookId}" already exists.`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
throw error;
}
}
async function loadCreationSource(input: {
readonly projectRoot: string;
readonly sourceText?: string;
readonly sourcePath?: string;
readonly sourceName?: string;
readonly purpose: "reference";
}): Promise<{ readonly text: string; readonly name: string }> {
if (input.sourceText?.trim()) {
return {
text: input.sourceText.trim(),
name: input.sourceName?.trim() || "source",
};
}
if (!input.sourcePath?.trim()) {
throw new Error("A sourceText or sourcePath is required.");
}
if (isAbsolute(input.sourcePath)) {
throw new Error("Creation sourcePath must be project-relative. Upload or ingest the file first.");
}
const sourcePath = safeChildPath(input.projectRoot, input.sourcePath);
const material = await ingestMaterial(input.projectRoot, {
sourceKind: "file",
filePath: sourcePath,
filename: basename(sourcePath),
title: input.sourceName?.trim() || basename(sourcePath).replace(/\.[^.]+$/u, ""),
purpose: input.purpose,
});
return {
text: await readFile(join(input.projectRoot, material.markdownPath), "utf-8"),
name: input.sourceName?.trim() || material.title,
};
}
function createDeterministicInteractionTools(pipeline: PipelineRunner, projectRoot: string) {
const state = new StateManager(projectRoot);
return createInteractionToolsFromDeps(pipeline, state);
@@ -166,9 +245,9 @@ const ProposeActionParams = Type.Object({
description: "One or two sentences explaining what will happen if the user confirms.",
})),
createBook: Type.Optional(Type.Object({
title: Type.Optional(Type.String({
title: Type.String({
description: "Confirmed long-form book title.",
})),
}),
genre: Type.Optional(Type.String({
description: "Confirmed book genre/category.",
})),
@@ -190,9 +269,12 @@ const ProposeActionParams = Type.Object({
})),
}, { description: "Structured execution args for action=create_book. Put platform/length here; do not leave them only in instruction text." })),
shortRun: Type.Optional(Type.Object({
direction: Type.Optional(Type.String({
title: Type.String({
description: "Confirmed standalone short title or working title. The host uses it as the stable project identity.",
}),
direction: Type.String({
description: "Confirmed standalone short direction.",
})),
}),
reference: Type.Optional(Type.String({
description: "Optional confirmed reference notes or constraints.",
})),
@@ -216,8 +298,8 @@ const ProposeActionParams = Type.Object({
})),
}, { description: "Structured execution args for action=short_run." })),
playStart: Type.Optional(Type.Object({
title: Type.Optional(Type.String({ description: "Confirmed interactive world title." })),
premise: Type.Optional(Type.String({ description: "Confirmed playable premise." })),
title: Type.String({ description: "Confirmed interactive world title." }),
premise: Type.String({ description: "Confirmed playable premise." }),
worldContract: Type.Optional(Type.String({
description: "Confirmed durable world contract in natural language: time semantics, role autonomy, object/clue/relationship rules, taboos, or other long-lived rules the user explicitly asked for. Do not invent RPG/level systems.",
})),
@@ -228,22 +310,22 @@ const ProposeActionParams = Type.Object({
Type.Literal("open"),
Type.Literal("guided"),
], { description: "Confirmed play mode: open for free actions, guided for suggested choices." })),
initialScene: Type.Optional(Type.String({
initialScene: Type.String({
description: "Confirmed opening scene shown to the player after confirmation. It must be pure narrative prose, not a title/setup/rules summary, not a question prompt, and not an action/options list.",
})),
}),
suggestedActions: Type.Optional(Type.Array(SuggestedActionParam, {
description: "Optional action springboards shown as separate UI chips. Do not include these in initialScene.",
})),
}, { description: "Structured execution args for action=play_start." })),
generateCover: Type.Optional(Type.Object({
title: Type.Optional(Type.String({ description: "Confirmed cover title." })),
title: Type.String({ description: "Confirmed cover title." }),
intro: Type.Optional(Type.String({ description: "Confirmed synopsis/hook for the cover." })),
sellingPoints: Type.Optional(Type.String({ description: "Confirmed selling points for the cover." })),
coverPrompt: Type.Optional(Type.String({ description: "Confirmed visual direction." })),
outputDir: Type.Optional(Type.String({ description: "Confirmed output directory." })),
}, { description: "Structured execution args for action=generate_cover." })),
scriptCreate: Type.Optional(Type.Object({
title: Type.Optional(Type.String({ description: "Confirmed script project title." })),
title: Type.String({ description: "Confirmed script project title." }),
sourceKind: Type.Optional(Type.String({ description: "Source type, e.g. novel excerpt, original idea, outline, existing script." })),
targetFormat: Type.Optional(Type.Union([
Type.Literal("vertical_short_drama"),
@@ -261,7 +343,7 @@ const ProposeActionParams = Type.Object({
outDir: Type.Optional(Type.String({ description: "Optional project-relative output directory. Default dramas/." })),
}, { description: "Structured execution args for action=script_create." })),
storyboardCreate: Type.Optional(Type.Object({
title: Type.Optional(Type.String({ description: "Confirmed storyboard project title." })),
title: Type.String({ description: "Confirmed storyboard project title." }),
sourceKind: Type.Optional(Type.String({ description: "Source type, e.g. script, novel excerpt, idea, scene list." })),
sourceText: Type.Optional(Type.String({ description: "User-provided source text. For long sources, prefer sourcePath instead of summarizing." })),
sourcePath: Type.Optional(Type.String({ description: "Optional project-relative source file path." })),
@@ -274,7 +356,7 @@ const ProposeActionParams = Type.Object({
outDir: Type.Optional(Type.String({ description: "Optional project-relative output directory. Default storyboards/." })),
}, { description: "Structured execution args for action=storyboard_create." })),
interactiveFilmCreate: Type.Optional(Type.Object({
title: Type.Optional(Type.String({ description: "Confirmed interactive-film project title." })),
title: Type.String({ description: "Confirmed interactive-film project title." }),
sourceKind: Type.Optional(Type.String({ description: "Source type, e.g. novel excerpt, script, outline, original idea." })),
sourceText: Type.Optional(Type.String({ description: "User-provided source text. For long sources, prefer sourcePath instead of summarizing." })),
sourcePath: Type.Optional(Type.String({ description: "Optional project-relative source file path." })),
@@ -288,19 +370,78 @@ const ProposeActionParams = Type.Object({
outDir: Type.Optional(Type.String({ description: "Optional project-relative output directory. Default interactive-films/." })),
}, { description: "Structured execution args for action=interactive_film_create." })),
translationCreate: Type.Optional(Type.Object({
filePath: Type.Optional(Type.String({ description: "Project-relative EPUB/PDF/TXT/Markdown source file path to translate." })),
sourceLanguage: Type.Optional(Type.String({ description: "Source language as a human-readable name, e.g. Auto detect, Japanese, English, Chinese (Simplified), 繁体中文(台湾). Do not require ISO abbreviations." })),
targetLanguage: Type.Optional(Type.String({ description: "Target language as a human-readable name, e.g. Chinese (Simplified), English, Japanese, Korean, Brazilian Portuguese. Do not require ISO abbreviations." })),
filePath: Type.String({ description: "Project-relative EPUB/PDF/TXT/Markdown source file path to translate." }),
sourceLanguage: Type.String({ description: "Source language as a human-readable name, e.g. Auto detect, Japanese, English, Chinese (Simplified), 繁体中文(台湾). Do not require ISO abbreviations." }),
targetLanguage: Type.String({ description: "Target language as a human-readable name, e.g. Chinese (Simplified), English, Japanese, Korean, Brazilian Portuguese. Do not require ISO abbreviations." }),
title: Type.Optional(Type.String({ description: "Optional translation project title." })),
segmentMaxChars: Type.Optional(Type.Number({ description: "Optional long-paragraph split threshold." })),
}, { description: "Structured execution args for action=translation_create." })),
fanficCreate: Type.Optional(Type.Object({
title: Type.String({ description: "Confirmed fanfiction book title." }),
sourceText: Type.Optional(Type.String({ description: "Provided canon/source text. Prefer sourcePath for uploaded or long files." })),
sourcePath: Type.Optional(Type.String({ description: "Project-relative uploaded canon/source file path." })),
sourceName: Type.Optional(Type.String({ description: "Human-readable source work name." })),
mode: Type.Optional(Type.Union([
Type.Literal("canon"),
Type.Literal("au"),
Type.Literal("ooc"),
Type.Literal("cp"),
], { description: "Confirmed fanfiction mode." })),
genre: Type.Optional(Type.String({ description: "Confirmed genre." })),
platform: Type.Optional(Type.Union([
Type.Literal("tomato"), Type.Literal("qidian"), Type.Literal("feilu"), Type.Literal("other"),
])),
language: Type.Optional(Type.Union([Type.Literal("zh"), Type.Literal("en")])),
targetChapters: Type.Optional(Type.Number({ description: "Confirmed total chapter count." })),
chapterWordCount: Type.Optional(Type.Number({ description: "Confirmed per-chapter length." })),
}, { description: "Structured execution args for action=fanfic_init. This creates the book directly after confirmation." })),
continuationImport: Type.Optional(Type.Object({
bookId: Type.Optional(Type.String({ description: "Existing target book id. Omit when creating a new continuation book." })),
title: Type.Optional(Type.String({ description: "New continuation book title when bookId is omitted." })),
sourcePath: Type.String({ description: "Project-relative uploaded novel file or chapter directory." }),
splitPattern: Type.Optional(Type.String({ description: "Optional custom chapter-heading regex source." })),
resumeFrom: Type.Optional(Type.Number({ description: "Resume interrupted replay from this 1-based chapter number." })),
genre: Type.Optional(Type.String({ description: "Genre for a newly created continuation book." })),
platform: Type.Optional(Type.Union([
Type.Literal("tomato"), Type.Literal("qidian"), Type.Literal("feilu"), Type.Literal("other"),
])),
language: Type.Optional(Type.Union([Type.Literal("zh"), Type.Literal("en")])),
targetChapters: Type.Optional(Type.Number({ description: "Target total chapters for a new book." })),
chapterWordCount: Type.Optional(Type.Number({ description: "Per-chapter length for a new book." })),
}, { description: "Structured execution args for action=continuation_import. This imports and rebuilds state directly after confirmation." })),
spinoffCreate: Type.Optional(Type.Object({
title: Type.String({ description: "Confirmed side-story title." }),
parentBookId: Type.String({ description: "Existing InkOS parent book id whose canon is inherited." }),
direction: Type.Optional(Type.String({ description: "Confirmed standalone side-story direction." })),
genre: Type.Optional(Type.String({ description: "Optional genre override; defaults to the parent book." })),
platform: Type.Optional(Type.Union([
Type.Literal("tomato"), Type.Literal("qidian"), Type.Literal("feilu"), Type.Literal("other"),
])),
language: Type.Optional(Type.Union([Type.Literal("zh"), Type.Literal("en")])),
targetChapters: Type.Optional(Type.Number({ description: "Optional chapter count; defaults to the parent book." })),
chapterWordCount: Type.Optional(Type.Number({ description: "Optional chapter length; defaults to the parent book." })),
}, { description: "Structured execution args for action=spinoff_create. This creates the side-story directly after confirmation." })),
imitationCreate: Type.Optional(Type.Object({
title: Type.String({ description: "Confirmed original imitation-project title." }),
referenceText: Type.Optional(Type.String({ description: "Reference prose. Prefer referencePath for uploaded or long files." })),
referencePath: Type.Optional(Type.String({ description: "Project-relative uploaded reference-work path." })),
storyIdea: Type.String({ description: "Confirmed original story idea; do not copy the reference plot." }),
sourceName: Type.Optional(Type.String({ description: "Human-readable reference work name." })),
genre: Type.Optional(Type.String({ description: "Confirmed genre." })),
platform: Type.Optional(Type.Union([
Type.Literal("tomato"), Type.Literal("qidian"), Type.Literal("feilu"), Type.Literal("other"),
])),
language: Type.Optional(Type.Union([Type.Literal("zh"), Type.Literal("en")])),
targetChapters: Type.Optional(Type.Number({ description: "Confirmed total chapter count." })),
chapterWordCount: Type.Optional(Type.Number({ description: "Confirmed per-chapter length." })),
}, { description: "Structured execution args for action=style_imitation. This creates an original book and style guide directly after confirmation." })),
});
type ProposeActionParamsType = Static<typeof ProposeActionParams>;
type ProposedActionTargetRoute = "import:fanfic" | "import:chapters" | "import:canon" | "import:spinoff" | "import:imitation" | "style";
type ProposeActionToolOptions = {
readonly sameSession?: boolean;
readonly requestedSkillIds?: () => ReadonlyArray<string>;
readonly attachmentPaths?: () => ReadonlyArray<string>;
};
function proposedActionSessionKind(action: ProposeActionParamsType["action"]): "book-create" | "short" | "play" | "script" | "storyboard" | "interactive-film" | "interactive-film-authoring" | "chat" {
@@ -315,14 +456,6 @@ function proposedActionSessionKind(action: ProposeActionParamsType["action"]): "
return "short";
}
function proposedActionTargetRoute(action: ProposeActionParamsType["action"]): ProposedActionTargetRoute | undefined {
if (action === "fanfic_init") return "import:fanfic";
if (action === "continuation_import") return "import:chapters";
if (action === "spinoff_create") return "import:spinoff";
if (action === "style_imitation") return "import:imitation";
return undefined;
}
function proposedActionFallbackTitle(action: ProposeActionParamsType["action"], isZh: boolean): string {
switch (action) {
case "create_book":
@@ -334,13 +467,13 @@ function proposedActionFallbackTitle(action: ProposeActionParamsType["action"],
case "generate_cover":
return isZh ? "生成封面" : "Generate cover";
case "fanfic_init":
return isZh ? "打开同人创作" : "Open fanfiction workflow";
return isZh ? "创建同人作品" : "Create fanfiction";
case "continuation_import":
return isZh ? "打开续写导入" : "Open continuation import";
return isZh ? "导入并续写作品" : "Import and continue a work";
case "spinoff_create":
return isZh ? "打开番外创作" : "Open side-story workflow";
return isZh ? "创建番外作品" : "Create a side story";
case "style_imitation":
return isZh ? "打开仿写/文风分析" : "Open style imitation";
return isZh ? "创建仿写作品" : "Create a style-imitation work";
case "script_create":
return isZh ? "创建剧本" : "Create script";
case "storyboard_create":
@@ -359,14 +492,9 @@ function proposedActionFallbackTitle(action: ProposeActionParamsType["action"],
}
function proposedActionFallbackSummary(action: ProposeActionParamsType["action"], isZh: boolean): string {
if (proposedActionTargetRoute(action)) {
return isZh
? "确认后只会打开现有 Studio 工具,不会直接生成成品。"
: "After confirmation, InkOS will only open the existing Studio tool; it will not generate finished content directly.";
}
return isZh
? "确认后会切换到对应入口并执行这条需求。"
: "After confirmation, InkOS will switch to the matching surface and run this request.";
? "确认后将直接执行这条需求;不会要求你再去另一个表单重复填写。"
: "After confirmation, InkOS will run this request directly without asking you to repeat it in another form.";
}
function compactObject<T extends Record<string, unknown>>(value: T | undefined): T | undefined {
@@ -451,6 +579,22 @@ function proposedActionPayload(
const translationCreate = compactObject(params.translationCreate);
if (translationCreate) payload.translationCreate = translationCreate;
}
if (params.action === "fanfic_init") {
const fanficCreate = compactObject(params.fanficCreate);
if (fanficCreate) payload.fanficCreate = fanficCreate;
}
if (params.action === "continuation_import") {
const continuationImport = compactObject(params.continuationImport);
if (continuationImport) payload.continuationImport = continuationImport;
}
if (params.action === "spinoff_create") {
const spinoffCreate = compactObject(params.spinoffCreate);
if (spinoffCreate) payload.spinoffCreate = spinoffCreate;
}
if (params.action === "style_imitation") {
const imitationCreate = compactObject(params.imitationCreate);
if (imitationCreate) payload.imitationCreate = imitationCreate;
}
return Object.keys(payload).length > 0 ? payload : undefined;
}
@@ -464,6 +608,48 @@ function validateProposedActionPayload(payload: ActionPayload | undefined): {
return { error: parsed.error.issues.map((issue) => issue.message).join("; ") };
}
function withSingleAttachmentFallback(
params: ProposeActionParamsType,
payload: ActionPayload | undefined,
attachmentPaths: ReadonlyArray<string>,
): ActionPayload | undefined {
const paths = [...new Set(attachmentPaths.map((path) => path.trim()).filter(Boolean))];
if (!payload || paths.length !== 1) return payload;
const [path] = paths;
const useHostAttachment = (candidate: string | undefined): boolean => {
const value = candidate?.trim();
return !value || (value.startsWith(".inkos/uploads/") && value !== path);
};
if (params.action === "translation_create" && payload.translationCreate && useHostAttachment(payload.translationCreate.filePath)) {
return { ...payload, translationCreate: { ...payload.translationCreate, filePath: path } };
}
if (
params.action === "fanfic_init"
&& payload.fanficCreate
&& !payload.fanficCreate.sourceText?.trim()
&& useHostAttachment(payload.fanficCreate.sourcePath)
) {
return { ...payload, fanficCreate: { ...payload.fanficCreate, sourcePath: path } };
}
if (
params.action === "continuation_import"
&& payload.continuationImport
&& useHostAttachment(payload.continuationImport.sourcePath)
) {
return { ...payload, continuationImport: { ...payload.continuationImport, sourcePath: path } };
}
if (
params.action === "style_imitation"
&& payload.imitationCreate
&& !payload.imitationCreate.referenceText?.trim()
&& useHostAttachment(payload.imitationCreate.referencePath)
) {
return { ...payload, imitationCreate: { ...payload.imitationCreate, referencePath: path } };
}
return payload;
}
function requireProposedText(value: string | undefined, label: string): void {
if (typeof value === "string" && value.trim().length > 0) return;
throw new Error(`propose_action is missing ${label}; retry with that field in the structured payload, not only in summary or instruction.`);
@@ -480,6 +666,11 @@ function assertExecutableProposedAction(params: ProposeActionParamsType, payload
requireProposedText(payload?.playStart?.initialScene, "playStart.initialScene");
return;
}
if (params.action === "short_run") {
requireProposedText(payload?.shortRun?.title, "shortRun.title");
requireProposedText(payload?.shortRun?.direction, "shortRun.direction");
return;
}
if (params.action === "generate_cover") {
requireProposedText(payload?.generateCover?.title, "generateCover.title");
return;
@@ -500,6 +691,33 @@ function assertExecutableProposedAction(params: ProposeActionParamsType, payload
requireProposedText(payload?.translationCreate?.filePath, "translationCreate.filePath");
requireProposedText(payload?.translationCreate?.sourceLanguage, "translationCreate.sourceLanguage");
requireProposedText(payload?.translationCreate?.targetLanguage, "translationCreate.targetLanguage");
return;
}
if (params.action === "fanfic_init") {
requireProposedText(payload?.fanficCreate?.title, "fanficCreate.title");
if (!payload?.fanficCreate?.sourceText?.trim() && !payload?.fanficCreate?.sourcePath?.trim()) {
throw new Error("propose_action is missing fanficCreate.sourceText/sourcePath; ask for or use the attached source before proposing production.");
}
return;
}
if (params.action === "continuation_import") {
requireProposedText(payload?.continuationImport?.sourcePath, "continuationImport.sourcePath");
if (!payload?.continuationImport?.bookId?.trim() && !payload?.continuationImport?.title?.trim()) {
throw new Error("propose_action requires continuationImport.bookId or continuationImport.title.");
}
return;
}
if (params.action === "spinoff_create") {
requireProposedText(payload?.spinoffCreate?.title, "spinoffCreate.title");
requireProposedText(payload?.spinoffCreate?.parentBookId, "spinoffCreate.parentBookId");
return;
}
if (params.action === "style_imitation") {
requireProposedText(payload?.imitationCreate?.title, "imitationCreate.title");
requireProposedText(payload?.imitationCreate?.storyIdea, "imitationCreate.storyIdea");
if (!payload?.imitationCreate?.referenceText?.trim() && !payload?.imitationCreate?.referencePath?.trim()) {
throw new Error("propose_action is missing imitationCreate.referenceText/referencePath; ask for or use the attached reference before proposing production.");
}
}
}
@@ -516,11 +734,14 @@ export function createProposeActionTool(
parameters: ProposeActionParams,
async execute(_toolCallId: string, params: ProposeActionParamsType): Promise<AgentToolResult<unknown>> {
const targetSessionKind = proposedActionSessionKind(params.action);
const targetRoute = proposedActionTargetRoute(params.action);
const isZh = language === "zh";
const title = params.title?.trim() || proposedActionFallbackTitle(params.action, isZh);
const summary = params.summary?.trim() || proposedActionFallbackSummary(params.action, isZh);
const proposedPayload = validateProposedActionPayload(proposedActionPayload(params, language));
const proposedPayload = validateProposedActionPayload(withSingleAttachmentFallback(
params,
proposedActionPayload(params, language),
options.attachmentPaths?.() ?? [],
));
if (proposedPayload.error) {
throw new Error(`Invalid proposed action payload: ${proposedPayload.error}`);
}
@@ -538,7 +759,6 @@ export function createProposeActionTool(
kind: "proposed_action",
action: params.action,
targetSessionKind,
...(targetRoute ? { targetRoute } : {}),
sameSession: options.sameSession === true,
title,
summary,
@@ -948,6 +1168,8 @@ export function createSubAgentTool(
wordCount: result.wordCount,
fixedIssues: result.fixedIssues,
skippedReason: result.skippedReason,
auditPassed: result.auditPassed,
auditIssues: result.auditIssues,
revisionDiagnostics: result.revisionDiagnostics,
skillIds,
};
@@ -975,8 +1197,13 @@ export function createSubAgentTool(
);
}
progress(`Revision complete for "${targetBookId}".`);
const auditText = result.auditPassed === undefined
? ""
: result.auditPassed
? " Audit passed."
: ` Audit still has ${(result.auditIssues ?? []).length} blocking issue(s).`;
return textResult(
`Revision (${resolvedMode}) complete for "${targetBookId}" chapter ${resultChapter ?? "latest"}.`,
`Revision (${resolvedMode}) complete for "${targetBookId}" chapter ${resultChapter ?? "latest"}.${auditText}`,
details,
);
}
@@ -1485,6 +1712,268 @@ export function createImportChaptersTool(
};
}
const FanficCreateParams = Type.Object({
title: Type.String({ description: "Fanfiction book title." }),
sourceText: Type.Optional(Type.String({ description: "Canon/source text. Prefer sourcePath for long material." })),
sourcePath: Type.Optional(Type.String({ description: "Project-relative uploaded canon/source path." })),
sourceName: Type.Optional(Type.String({ description: "Human-readable source work name." })),
mode: Type.Optional(Type.Union([
Type.Literal("canon"), Type.Literal("au"), Type.Literal("ooc"), Type.Literal("cp"),
])),
genre: Type.Optional(Type.String()),
platform: Type.Optional(Type.Union([
Type.Literal("tomato"), Type.Literal("qidian"), Type.Literal("feilu"), Type.Literal("other"),
])),
language: Type.Optional(Type.Union([Type.Literal("zh"), Type.Literal("en")])),
targetChapters: Type.Optional(Type.Integer({ minimum: 1 })),
chapterWordCount: Type.Optional(Type.Integer({ minimum: 1 })),
});
type FanficCreateParamsType = Static<typeof FanficCreateParams>;
export function createFanficBookTool(
pipeline: PipelineRunner,
projectRoot: string,
options: SkillAwareProductionOptions = {},
): AgentTool<typeof FanficCreateParams> {
return {
name: "fanfic_create",
description: "Create an InkOS fanfiction book directly from supplied canon/source material after user confirmation.",
label: "Create Fanfiction",
parameters: FanficCreateParams,
async execute(_toolCallId, params: FanficCreateParamsType, signal, onUpdate) {
const source = await loadCreationSource({
projectRoot,
sourceText: params.sourceText,
sourcePath: params.sourcePath,
sourceName: params.sourceName,
purpose: "reference",
});
const mode = params.mode ?? "canon";
const book = buildAgentBookConfig({
...params,
fanficMode: mode,
}, { targetChapters: 100 });
await assertBookDoesNotExist(projectRoot, book.id);
const activatedSkills = resolveProductionToolSkills(options);
onUpdate?.(textResult(`Creating fanfiction book "${book.title}" from ${source.name}...`));
await runPipelineWithAgentContext(pipeline, signal, activatedSkills, () => (
pipeline.initFanficBook(book, source.text, source.name, mode)
));
return textResult(
`Created fanfiction book "${book.title}" (${book.id}) in ${mode} mode.`,
{
kind: "book_created",
creationKind: "fanfic",
bookId: book.id,
title: book.title,
fanficMode: mode,
sourceName: source.name,
skillIds: activatedSkillIds(activatedSkills),
},
);
},
};
}
const SpinoffCreateParams = Type.Object({
title: Type.String({ description: "Standalone side-story title." }),
parentBookId: Type.String({ description: "Existing InkOS parent book id." }),
direction: Type.Optional(Type.String({ description: "Side-story direction that must not advance the parent mainline." })),
genre: Type.Optional(Type.String()),
platform: Type.Optional(Type.Union([
Type.Literal("tomato"), Type.Literal("qidian"), Type.Literal("feilu"), Type.Literal("other"),
])),
language: Type.Optional(Type.Union([Type.Literal("zh"), Type.Literal("en")])),
targetChapters: Type.Optional(Type.Integer({ minimum: 1 })),
chapterWordCount: Type.Optional(Type.Integer({ minimum: 1 })),
});
type SpinoffCreateParamsType = Static<typeof SpinoffCreateParams>;
export function createSpinoffBookTool(
pipeline: PipelineRunner,
projectRoot: string,
options: SkillAwareProductionOptions = {},
): AgentTool<typeof SpinoffCreateParams> {
return {
name: "spinoff_create",
description: "Create a standalone side story that inherits canon from an existing InkOS parent book.",
label: "Create Side Story",
parameters: SpinoffCreateParams,
async execute(_toolCallId, params: SpinoffCreateParamsType, signal, onUpdate) {
const parentBookId = assertSafeBookId(params.parentBookId, "spinoff_create.parentBookId");
const state = new StateManager(projectRoot);
const parent = await state.loadBookConfig(parentBookId);
const book = buildAgentBookConfig({
...params,
parentBookId,
genre: params.genre ?? parent.genre,
platform: params.platform ?? parent.platform,
language: params.language ?? parent.language,
targetChapters: params.targetChapters ?? parent.targetChapters,
chapterWordCount: params.chapterWordCount ?? parent.chapterWordCount,
});
await assertBookDoesNotExist(projectRoot, book.id);
const activatedSkills = resolveProductionToolSkills(options);
onUpdate?.(textResult(`Creating side story "${book.title}" from parent book "${parent.title}"...`));
await runPipelineWithAgentContext(pipeline, signal, activatedSkills, () => (
pipeline.initSpinoffBook(book, parentBookId, params.direction)
));
return textResult(
`Created side-story book "${book.title}" (${book.id}) from "${parent.title}".`,
{
kind: "book_created",
creationKind: "spinoff",
bookId: book.id,
title: book.title,
parentBookId,
skillIds: activatedSkillIds(activatedSkills),
},
);
},
};
}
const ImitationCreateParams = Type.Object({
title: Type.String({ description: "Original imitation-project title." }),
referenceText: Type.Optional(Type.String({ description: "Reference prose. Prefer referencePath for long material." })),
referencePath: Type.Optional(Type.String({ description: "Project-relative uploaded reference-work path." })),
storyIdea: Type.String({ description: "Original story idea. The reference contributes prose style, not plot or characters." }),
sourceName: Type.Optional(Type.String({ description: "Human-readable reference work name." })),
genre: Type.Optional(Type.String()),
platform: Type.Optional(Type.Union([
Type.Literal("tomato"), Type.Literal("qidian"), Type.Literal("feilu"), Type.Literal("other"),
])),
language: Type.Optional(Type.Union([Type.Literal("zh"), Type.Literal("en")])),
targetChapters: Type.Optional(Type.Integer({ minimum: 1 })),
chapterWordCount: Type.Optional(Type.Integer({ minimum: 1 })),
});
type ImitationCreateParamsType = Static<typeof ImitationCreateParams>;
export function createImitationBookTool(
pipeline: PipelineRunner,
projectRoot: string,
options: SkillAwareProductionOptions = {},
): AgentTool<typeof ImitationCreateParams> {
return {
name: "imitation_create",
description: "Create an original InkOS book and derive its prose style guide from supplied reference writing.",
label: "Create Style Imitation",
parameters: ImitationCreateParams,
async execute(_toolCallId, params: ImitationCreateParamsType, signal, onUpdate) {
const reference = await loadCreationSource({
projectRoot,
sourceText: params.referenceText,
sourcePath: params.referencePath,
sourceName: params.sourceName,
purpose: "reference",
});
const book = buildAgentBookConfig(params);
await assertBookDoesNotExist(projectRoot, book.id);
const activatedSkills = resolveProductionToolSkills(options);
onUpdate?.(textResult(`Creating original book "${book.title}" with style reference ${reference.name}...`));
await runPipelineWithAgentContext(pipeline, signal, activatedSkills, () => (
pipeline.initImitationBook(book, reference.text, params.storyIdea, reference.name)
));
return textResult(
`Created imitation book "${book.title}" (${book.id}) with a persisted style guide.`,
{
kind: "book_created",
creationKind: "imitation",
bookId: book.id,
title: book.title,
sourceName: reference.name,
skillIds: activatedSkillIds(activatedSkills),
},
);
},
};
}
const ContinuationImportParams = Type.Object({
bookId: Type.Optional(Type.String({ description: "Existing target book id. Omit to create a new continuation book." })),
title: Type.Optional(Type.String({ description: "New book title when bookId is omitted." })),
sourcePath: Type.String({ description: "Project-relative uploaded novel file or chapter directory." }),
splitPattern: Type.Optional(Type.String({ description: "Optional custom chapter-heading regex source." })),
resumeFrom: Type.Optional(Type.Integer({ minimum: 1 })),
genre: Type.Optional(Type.String()),
platform: Type.Optional(Type.Union([
Type.Literal("tomato"), Type.Literal("qidian"), Type.Literal("feilu"), Type.Literal("other"),
])),
language: Type.Optional(Type.Union([Type.Literal("zh"), Type.Literal("en")])),
targetChapters: Type.Optional(Type.Integer({ minimum: 1 })),
chapterWordCount: Type.Optional(Type.Integer({ minimum: 1 })),
});
type ContinuationImportParamsType = Static<typeof ContinuationImportParams>;
export function createContinuationImportTool(
pipeline: PipelineRunner,
activeBookId: string | null,
projectRoot: string,
options: SkillAwareProductionOptions = {},
): AgentTool<typeof ContinuationImportParams> {
return {
name: "continuation_import",
description: "Import an uploaded novel into an existing or newly created InkOS book, rebuild story state, and prepare it for continuation.",
label: "Import for Continuation",
parameters: ContinuationImportParams,
async execute(_toolCallId, params: ContinuationImportParamsType, signal, onUpdate) {
if (isAbsolute(params.sourcePath)) {
throw new Error("continuation_import.sourcePath must be project-relative. Upload the source first.");
}
const sourcePath = safeChildPath(projectRoot, params.sourcePath);
const state = new StateManager(projectRoot);
const requestedBookId = params.bookId ?? activeBookId ?? undefined;
let bookId: string;
let created = false;
if (requestedBookId) {
bookId = resolveToolBookId("continuation_import", requestedBookId, activeBookId);
await state.loadBookConfig(bookId);
} else {
if (!params.title?.trim()) {
throw new Error("continuation_import requires title when no existing bookId is selected.");
}
const book = buildAgentBookConfig({ ...params, title: params.title.trim() });
await assertBookDoesNotExist(projectRoot, book.id);
await state.saveBookConfig(book.id, book);
bookId = book.id;
created = true;
}
const existingChapterCount = (await state.getNextChapterNumber(bookId)) - 1;
if (existingChapterCount > 0 && params.resumeFrom === undefined) {
throw new Error(`Book "${bookId}" already has ${existingChapterCount} chapter(s); resumeFrom is required.`);
}
const chapters = await loadChaptersFromPath(sourcePath, params.splitPattern);
const activatedSkills = resolveProductionToolSkills(options);
onUpdate?.(textResult(`Importing ${chapters.length} chapter(s) into "${bookId}" and rebuilding story state...`));
const result = await runPipelineWithAgentContext(pipeline, signal, activatedSkills, () => (
pipeline.importChapters({
bookId,
chapters,
resumeFrom: params.resumeFrom,
importMode: "continuation",
})
));
return textResult(
`Imported ${result.importedCount} chapter(s) into "${bookId}". Next chapter: ${result.nextChapter}.`,
{
kind: created ? "book_created" : "chapters_imported",
creationKind: "continuation",
bookId,
importedCount: result.importedCount,
totalWords: result.totalWords,
nextChapter: result.nextChapter,
skillIds: activatedSkillIds(activatedSkills),
},
);
},
};
}
function slugResearchTopic(topic: string): string {
const slug = topic
.normalize("NFKC")
@@ -1509,6 +1998,9 @@ async function readResearchSearchConfig(projectRoot: string) {
// ---------------------------------------------------------------------------
const ShortFictionRunParams = Type.Object({
title: Type.Optional(Type.String({
description: "Confirmed title or working title. When present, the host uses it as the stable project identity instead of guessing from generated outline prose.",
})),
direction: Type.String({
description: "Required short fiction direction, e.g. 女频短篇 婚姻背叛 证据反杀. Include genre, protagonist pressure, conflict, and desired payoff when known.",
}),
@@ -1594,6 +2086,7 @@ export function createShortFictionRunTool(
activatedSkills,
() => runShortFictionProduction({
projectRoot,
title: shortPayload?.title ?? params.title,
direction: shortPayload?.direction ?? params.direction,
runtimes: {
planner: pipeline.createAgentContext("short-outline"),
@@ -2135,6 +2628,7 @@ export interface PlayStartToolOptions extends SkillAwareProductionOptions {
readonly worldId: string;
readonly runId: string;
readonly ctx: AgentContext;
readonly db: PlayGraphDB;
}) => { seedOpening(input: { sceneText: string; suggestedActions?: readonly string[] }): Promise<PlayOpeningSeedResult | null> };
}
@@ -2160,6 +2654,9 @@ export function createPlayStartTool(
): Promise<AgentToolResult<unknown>> {
_signal?.throwIfAborted();
onUpdate?.(textResult("Starting interactive world..."));
if (!pipeline) {
throw new Error("play_start requires an initialized InkOS pipeline to create authoritative world state.");
}
const playPayload = options.actionPayload?.playStart;
const activatedSkills = resolveProductionToolSkills(options);
const store = new PlayStore(projectRoot);
@@ -2174,6 +2671,7 @@ export function createPlayStartTool(
const visualContract = playPayload?.visualContract ?? params.visualContract;
const initialScene = playPayload?.initialScene?.trim() || params.initialScene;
const playLanguage = inferLanguage([title, premise, worldContract, visualContract, initialScene].filter(Boolean).join("\n"));
const existingWorld = await store.loadWorld(worldId);
const world = await store.createWorld({
id: worldId,
title: title.trim(),
@@ -2189,28 +2687,10 @@ export function createPlayStartTool(
const sceneText = (initialScene?.trim() || (world.language === "en"
? [`You enter "${world.title}".`, world.premise || "The scene is set. Make your first move."].join("\n")
: [`你进入「${world.title}」。`, world.premise || "场景已经就位,等待你的第一个动作。"].join("\n"))).trim();
if (existingTranscript.length === 0) {
await store.writeProjection(world.id, runId, "projections/scene.md", `${sceneText}\n`);
await store.saveCurrentState(world.id, runId, {
turn: 0,
worldId: world.id,
runId,
mode: world.mode,
premise: world.premise,
worldContract: world.worldContract,
visualContract: world.visualContract,
});
await store.appendTranscriptTurn(world.id, runId, {
role: "assistant",
content: sceneText,
timestamp: Date.now(),
});
}
const suggestedActions = normalizeSuggestedActions(playPayload?.suggestedActions ?? params.suggestedActions);
let seed: PlayOpeningSeedResult | null = null;
let graph;
if (existingTranscript.length === 0 && pipeline) {
try {
const db = createPlayDB(store.runDir(world.id, runId));
try {
seed = await runPipelineWithAgentContext(
@@ -2224,6 +2704,7 @@ export function createPlayStartTool(
worldId: world.id,
runId,
ctx,
db,
}) ?? new PlayRunner({
projectRoot,
worldId: world.id,
@@ -2236,13 +2717,38 @@ export function createPlayStartTool(
);
_signal?.throwIfAborted();
graph = db.snapshot();
} catch {
_signal?.throwIfAborted();
// Opening graph seed is a HUD enhancement, not a launch precondition.
// Starting the world must stay fail-open when a model drifts.
} finally {
closePlayDB(db);
}
if (!graph?.entities?.some((entity) => entity.id === "actor_player")
|| !graph.entities.some((entity) => entity.id !== "actor_player")) {
throw new Error(world.language === "en"
? "Play opening state is incomplete: no usable player/world graph was created."
: "互动世界开场状态不完整:没有生成可用的玩家与世界图谱。");
}
if (existingTranscript.length === 0) {
await store.writeProjection(world.id, runId, "projections/scene.md", `${sceneText}\n`);
await store.saveCurrentState(world.id, runId, {
turn: 0,
worldId: world.id,
runId,
mode: world.mode,
premise: world.premise,
worldContract: world.worldContract,
visualContract: world.visualContract,
});
await store.appendTranscriptTurn(world.id, runId, {
role: "assistant",
content: sceneText,
timestamp: Date.now(),
});
}
} catch (error) {
_signal?.throwIfAborted();
if (!existingWorld) await store.removeWorld(world.id);
throw error;
}
return textResult(
@@ -2974,32 +3480,93 @@ export function createReplaceChapterTextTool(
};
}
const ResyncChapterStateParams = Type.Object({
bookId: Type.Optional(Type.String({ description: "Book ID. Omit to use the active book." })),
chapterNumber: Type.Optional(Type.Number({ description: "Latest chapter number to rebuild from its persisted body. Omit to use the latest chapter." })),
allowNewHooks: Type.Optional(Type.Boolean({
description:
"Whether settlement may create brand-new hook IDs. Set false when the user asks to preserve stable hook IDs, avoid replacement hooks, or only repair existing truth state.",
})),
});
export function createResyncChapterStateTool(
pipeline: PipelineRunner,
activeBookId: string | null,
options: SkillAwareProductionOptions & { readonly language?: "zh" | "en" } = {},
): AgentTool<typeof ResyncChapterStateParams> {
return {
name: "resync_chapter_state",
description:
"Keep the persisted chapter body unchanged, rebuild its derived story state, summaries, and hooks from the previous chapter snapshot, then run a fresh audit. " +
"Use after an explicit chapter edit or when the user asks to repair/synchronize truth state without rewriting prose. Only the latest chapter is supported.",
label: "Resync Chapter State",
parameters: ResyncChapterStateParams,
async execute(_toolCallId, params, signal): Promise<AgentToolResult<unknown>> {
const bookId = resolveToolBookId("resync_chapter_state", params.bookId, activeBookId);
const activatedSkills = resolveProductionToolSkills(options);
const result = await runPipelineWithAgentContext(
pipeline,
signal,
activatedSkills,
() => pipeline.resyncChapterStateAndAudit(bookId, params.chapterNumber, {
allowNewHooks: params.allowNewHooks,
}),
);
const issues = result.audit.issues;
const zh = options.language !== "en";
const summary = result.audit.passed
? (zh
? `${result.chapter.chapterNumber} 章正文未改动;状态、摘要与伏笔已从上一章快照重建,重新审稿通过。`
: `Chapter ${result.chapter.chapterNumber} prose was unchanged; state, summaries, and hooks were rebuilt from the previous snapshot, and the fresh audit passed.`)
: [
zh
? `${result.chapter.chapterNumber} 章正文未改动;状态、摘要与伏笔已重建,但重新审稿仍有 ${issues.length} 个问题:`
: `Chapter ${result.chapter.chapterNumber} prose was unchanged; state, summaries, and hooks were rebuilt, but the fresh audit still found ${issues.length} issue(s):`,
...issues.map((issue) => `- [${issue.severity}] ${issue.description}${issue.suggestion ? ` (${issue.suggestion})` : ""}`),
].join("\n");
return textResult(summary, {
kind: "chapter_state_resynced",
bookId,
chapterNumber: result.chapter.chapterNumber,
status: result.audit.passed ? "ready-for-review" : "audit-failed",
auditPassed: result.audit.passed,
auditIssues: issues,
summary: result.audit.summary,
skillIds: activatedSkillIds(activatedSkills),
});
},
};
}
// ---------------------------------------------------------------------------
// 3. Read Tool
// ---------------------------------------------------------------------------
const ReadParams = Type.Object({
path: Type.String({ description: "File path relative to books/, or an absolute path when system path reading is enabled." }),
path: Type.String({ description: "File path relative to the tool's permitted read root, or an absolute path when system path reading is enabled." }),
});
export interface ReadToolOptions {
readonly allowSystemPaths?: boolean;
readonly scope?: "books" | "project";
}
function resolveReadPath(booksRoot: string, requestedPath: string, options: ReadToolOptions): string {
function resolveReadPath(readRoot: string, requestedPath: string, options: ReadToolOptions): string {
if (options.allowSystemPaths && isAbsolute(requestedPath)) {
return resolve(requestedPath);
}
return safeBooksPath(booksRoot, requestedPath);
return safeChildPath(readRoot, requestedPath);
}
export function createReadTool(
projectRoot: string,
options: ReadToolOptions = {},
): AgentTool<typeof ReadParams> {
const booksRoot = join(projectRoot, "books");
const readRoot = options.scope === "project" ? projectRoot : join(projectRoot, "books");
const description = options.allowSystemPaths
? "Read a file. Relative paths resolve under books/; absolute paths read from the system filesystem."
: options.scope === "project"
? "Read a UTF-8 file inside the current InkOS project. Path is relative to the project root."
: "Read a file from the book directory. Path is relative to books/.";
return {
@@ -3012,7 +3579,7 @@ export function createReadTool(
params: Static<typeof ReadParams>,
): Promise<AgentToolResult<undefined>> {
try {
const filePath = resolveReadPath(booksRoot, params.path, options);
const filePath = resolveReadPath(readRoot, params.path, options);
const content = await readFile(filePath, "utf-8");
return textResult(content);
} catch (err: any) {
@@ -4,6 +4,7 @@ import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { isNewLayoutBook } from "../utils/outline-paths.js";
import type { ContextCompressionCallback } from "../models/context-compression.js";
import { loadStoryGraph } from "../interactive-film/graph-store.js";
/** Files read in this order; anything else in story/ comes after, sorted alphabetically. */
const PRIORITY_FILES = [
@@ -80,6 +81,31 @@ export function createBookContextTransform(
};
}
/**
* Inject the complete authoritative interactive-film graph for authoring turns.
* Node ids, choices, conditions and effects are execution state, so silently
* excerpting them would make edits unsafe. Context-window guards remain the
* explicit failure boundary until semantic graph compaction is introduced.
*/
export function createInteractiveFilmContextTransform(
projectId: string,
projectRoot: string,
): (messages: AgentMessage[], signal?: AbortSignal) => Promise<AgentMessage[]> {
return async (messages) => {
const graph = await loadStoryGraph(projectRoot, projectId);
if (!graph) return messages;
const injected: UserMessage = {
role: "user",
content: [
"[以下是当前互动影游的完整权威剧情图谱,每轮从磁盘重新读取。编辑时必须使用其中真实的 node id、choice id、变量和结局 id;不要凭空臆造。]",
JSON.stringify(graph),
].join("\n"),
timestamp: Date.now(),
};
return [injected, ...messages];
};
}
interface TruthFileSection {
name: string;
content: string;
+84 -23
View File
@@ -2,10 +2,9 @@ import { Type, type Static } from "@mariozechner/pi-ai";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { applyGraphDelta } from "../interactive-film/authoring-store.js";
import type { LLMClient } from "../llm/provider.js";
import { runWorkerAgent } from "./worker-agent.js";
import { runWorkerAgentTool } from "./worker-agent.js";
import { loadStoryGraph } from "../interactive-film/graph-store.js";
import { buildFilmAuthoringContext } from "../interactive-film/film-context.js";
import { buildFillNodeDeltaFromLLMText, buildStructureDeltaFromLLMText } from "../interactive-film/authoring-generate.js";
import {
buildWorldAnchorDelta,
buildAddVariableDelta,
@@ -14,7 +13,8 @@ import {
buildConnectChoiceDelta,
buildRemoveNodeDelta,
} from "../interactive-film/authoring-tools.js";
import { StoryNodeSchema } from "../interactive-film/graph-schema.js";
import { StoryNodeSchema, type StoryNode } from "../interactive-film/graph-schema.js";
import { StoryNodeContentToolSchema, StoryStructureToolSchema } from "../interactive-film/tool-schemas.js";
import { writeCharacterFacts } from "../interactive-film/memory-link.js";
import { MemoryDB } from "../state/memory-db.js";
import { join } from "node:path";
@@ -170,21 +170,55 @@ export function createUpsertCharactersTool(projectRoot: string, projectId: strin
// ---------------------------------------------------------------------------
export interface FilmLLMDeps {
readonly chat: (system: string, user: string, signal?: AbortSignal) => Promise<string>;
readonly submitNode: (
system: string,
user: string,
nodeId: string,
signal?: AbortSignal,
) => Promise<StoryNode>;
readonly submitStructure: (
system: string,
user: string,
signal?: AbortSignal,
) => Promise<ReadonlyArray<StoryNode>>;
readonly skillIds?: () => ReadonlyArray<string>;
}
function defaultChat(
function defaultSubmitNode(
client: LLMClient,
model: string,
activatedSkills?: () => ReadonlyArray<ActivatedSkillGuidance>,
): FilmLLMDeps["chat"] {
return async (system, user, signal) => {
const res = await runWorkerAgent(client, model, appendActivatedSkillGuidance([
): FilmLLMDeps["submitNode"] {
return async (system, user, nodeId, signal) => {
const submitted = await runWorkerAgentTool(client, model, appendActivatedSkillGuidance([
{ role: "system", content: system },
{ role: "user", content: user },
], activatedSkills?.()), { temperature: 0.6, maxTokens: 4000, signal });
return res.content;
], activatedSkills?.()), {
name: "submit_story_node",
label: "Submit Story Node",
description: "Submit the complete scene, dialogue, choices, and image direction for the requested node. The host owns the node id.",
parameters: StoryNodeContentToolSchema,
}, { temperature: 0.6, maxTokens: 4000, signal });
return StoryNodeSchema.parse({ ...submitted, id: nodeId });
};
}
function defaultSubmitStructure(
client: LLMClient,
model: string,
activatedSkills?: () => ReadonlyArray<ActivatedSkillGuidance>,
): FilmLLMDeps["submitStructure"] {
return async (system, user, signal) => {
const submitted = await runWorkerAgentTool(client, model, appendActivatedSkillGuidance([
{ role: "system", content: system },
{ role: "user", content: user },
], activatedSkills?.()), {
name: "submit_story_structure",
label: "Submit Story Structure",
description: "Submit the complete branching node skeleton. Node ids and choice targets must form one connected playable graph.",
parameters: StoryStructureToolSchema,
}, { temperature: 0.6, maxTokens: 6000, signal });
return submitted.nodes.map((node) => StoryNodeSchema.parse(node));
};
}
@@ -195,8 +229,8 @@ const FillNodeParams = Type.Object({
export type FilmAuthoringLanguage = "zh" | "en";
const NODE_SYSTEM_ZH = `你是互动影游编剧。根据当前图上下文和指令,指定节点生成 JSON(单个 StoryNodetype/title/sceneDesc/dialogue[]/choices[]),只输出 JSON。choices[].targetNodeId 必须指向已存在的节点 id。`;
const NODE_SYSTEM_EN = `You are an interactive film scriptwriter. Using the current graph context and the instruction, generate JSON for the specified node (a single StoryNode: type/title/sceneDesc/dialogue[]/choices[]). Output JSON only. Every choices[].targetNodeId must point to an existing node id.`;
const NODE_SYSTEM_ZH = `你是互动影游编剧。根据当前图上下文和指令,写出指定节点的完整场景、对白、选项和配图方向。choices[].targetNodeId 必须指向已存在的节点 id。完成后调用 submit_story_node。`;
const NODE_SYSTEM_EN = `You are an interactive film scriptwriter. Using the current graph context and the instruction, write the requested node's complete scene, dialogue, choices, and image direction. Every choices[].targetNodeId must point to an existing node id. Finish by calling submit_story_node.`;
function nodeSystemPrompt(language: FilmAuthoringLanguage): string {
return language === "en" ? NODE_SYSTEM_EN : NODE_SYSTEM_ZH;
@@ -232,8 +266,13 @@ export function createFillNodeTool(
const userPrompt = language === "en"
? `${context}\n\nNode id to fill: ${params.nodeId}\nInstruction: ${params.instruction}`
: `${context}\n\n要填的节点 id${params.nodeId}\n指令:${params.instruction}`;
const text = await deps.chat(systemPrompt, userPrompt, signal);
const { rev } = await applyGraphDelta({ projectRoot, projectId, delta: buildFillNodeDeltaFromLLMText(text, params.nodeId), phase: "workshop" });
const node = await deps.submitNode(systemPrompt, userPrompt, params.nodeId, signal);
const { rev } = await applyGraphDelta({
projectRoot,
projectId,
delta: { nodes: { upsert: [node], remove: [] }, notes: [] },
phase: "workshop",
});
return textResult(`Node ${params.nodeId} filled (rev ${rev}).`, graphUpdatedDetails(rev, "interactive-film.script", {
skillIds: deps.skillIds?.() ?? [],
}));
@@ -263,8 +302,13 @@ export function createReviseNodeTool(
const userPrompt = language === "en"
? `${context}\n\nNode id to revise: ${params.nodeId}\nCurrent content: ${JSON.stringify(current ?? {})}\nRevision instruction: ${params.instruction}`
: `${context}\n\n要修改的节点 id${params.nodeId}\n现有内容:${JSON.stringify(current ?? {})}\n修改指令:${params.instruction}`;
const text = await deps.chat(systemPrompt, userPrompt, signal);
const { rev } = await applyGraphDelta({ projectRoot, projectId, delta: buildFillNodeDeltaFromLLMText(text, params.nodeId), phase: "workshop" });
const node = await deps.submitNode(systemPrompt, userPrompt, params.nodeId, signal);
const { rev } = await applyGraphDelta({
projectRoot,
projectId,
delta: { nodes: { upsert: [node], remove: [] }, notes: [] },
phase: "workshop",
});
return textResult(`Node ${params.nodeId} revised (rev ${rev}).`, graphUpdatedDetails(rev, "interactive-film.script", {
skillIds: deps.skillIds?.() ?? [],
}));
@@ -278,21 +322,22 @@ export function filmLLMDepsFromClient(
options: { readonly activatedSkills?: () => ReadonlyArray<ActivatedSkillGuidance> } = {},
): FilmLLMDeps {
return {
chat: defaultChat(client, model, options.activatedSkills),
submitNode: defaultSubmitNode(client, model, options.activatedSkills),
submitStructure: defaultSubmitStructure(client, model, options.activatedSkills),
skillIds: () => (options.activatedSkills?.() ?? []).map((activation) => activation.skill.id),
};
}
// ---------------------------------------------------------------------------
// draft_structure — confirm-class: LLM → buildStructureDeltaFromLLMText → apply
// draft_structure — confirm-class: structured worker result → apply
// ---------------------------------------------------------------------------
const DraftStructureParams = Type.Object({
instruction: Type.String({ description: "what skeleton to draft (acts, branch points, endings)" }),
});
const STRUCT_SYSTEM_ZH = `你是互动影游编剧。根据上下文与指令,生成分支骨架 JSON{ "nodes": [StoryNode...] }。恰好 1 个 type=start,至少 2 个 branch,至少 2 个差异化 ending 节点;每条路径都能到某个 ending;只输出 JSON`;
const STRUCT_SYSTEM_EN = `You are an interactive film scriptwriter. Using the context and the instruction, generate the branching skeleton as JSON: { "nodes": [StoryNode...] }. Exactly 1 node with type=start; at least 2 branch nodes; at least 2 clearly differentiated ending nodes; every path must reach some ending. Output JSON only.`;
const STRUCT_SYSTEM_ZH = `你是互动影游编剧。根据上下文与指令设计分支骨架。恰好 1 个 type=start,至少 2 个 branch,至少 2 个差异化 ending 节点;每条路径都能到某个 ending。完成后调用 submit_story_structure`;
const STRUCT_SYSTEM_EN = `You are an interactive film scriptwriter. Using the context and the instruction, design the branching skeleton. Include exactly 1 node with type=start, at least 2 branch nodes, and at least 2 clearly differentiated ending nodes; every path must reach an ending. Finish by calling submit_story_structure.`;
export function createDraftStructureTool(
projectRoot: string,
@@ -315,8 +360,13 @@ export function createDraftStructureTool(
const userPrompt = language === "en"
? `${context}\n\nSkeleton instruction: ${params.instruction}`
: `${context}\n\n骨架指令:${params.instruction}`;
const text = await deps.chat(systemPrompt, userPrompt, signal);
const { graph: next, rev } = await applyGraphDelta({ projectRoot, projectId, delta: buildStructureDeltaFromLLMText(text), phase: "structure" });
const nodes = await deps.submitStructure(systemPrompt, userPrompt, signal);
const { graph: next, rev } = await applyGraphDelta({
projectRoot,
projectId,
delta: { nodes: { upsert: [...nodes], remove: [] }, notes: [] },
phase: "structure",
});
return textResult(`Structure drafted: ${next.nodes.length} nodes (rev ${rev}).`, graphUpdatedDetails(rev, "interactive-film.story-graph", {
skillIds: deps.skillIds?.() ?? [],
}));
@@ -379,6 +429,11 @@ export function createRemoveNodeTool(
const GenerateNodeImageParams = Type.Object({
nodeId: Type.String({ description: "the node to generate a shot image for (uses its imageSlot.prompt or sceneDesc)" }),
size: Type.Optional(Type.Union([
Type.Literal("1536x1024"),
Type.Literal("1024x1536"),
Type.Literal("1024x1024"),
], { description: "output image size; use 1536x1024 for landscape film frames, 1024x1536 for portrait, or 1024x1024 for square" })),
});
export function createGenerateNodeImageTool(projectRoot: string, projectId: string, deps?: NodeImageDeps): AgentTool<typeof GenerateNodeImageParams> {
@@ -393,7 +448,13 @@ export function createGenerateNodeImageTool(projectRoot: string, projectId: stri
const node = graph.nodes.find((n) => n.id === params.nodeId);
if (!node) throw new Error(`node ${params.nodeId} not found`);
const imageDeps = deps ?? (await defaultNodeImageDeps(projectRoot));
const { assetRef, delta } = await generateNodeImage({ projectRoot, projectId, node, deps: imageDeps });
const { assetRef, delta } = await generateNodeImage({
projectRoot,
projectId,
node,
size: params.size,
deps: imageDeps,
});
const { rev } = await applyGraphDelta({ projectRoot, projectId, delta });
return textResult(`Generated image for node ${params.nodeId} (rev ${rev}).`, { kind: "graph_updated", rev, assetRef });
},
+8 -1
View File
@@ -12,6 +12,10 @@ export {
createStoryboardCreationTool,
createInteractiveFilmCreationTool,
createTranslationCreateTool,
createFanficBookTool,
createContinuationImportTool,
createSpinoffBookTool,
createImitationBookTool,
createResearchWebTool,
createIngestMaterialTool,
createManageBookReferenceTool,
@@ -31,7 +35,10 @@ export {
type AgentSessionConfig,
type AgentSessionResult,
} from "./agent-session.js";
export { createBookContextTransform } from "./context-transform.js";
export {
createBookContextTransform,
createInteractiveFilmContextTransform,
} from "./context-transform.js";
export { createUseSkillTool, type CreateUseSkillToolOptions } from "./skill-tool.js";
export {
createSetWorldAnchorTool,
+53
View File
@@ -0,0 +1,53 @@
import { streamSimple } from "@mariozechner/pi-ai";
import type {
Api,
AssistantMessageEventStream,
Context,
Model,
SimpleStreamOptions,
} from "@mariozechner/pi-ai";
import {
assertWithinContextWindow,
estimatePiContextTokens,
guardAssistantMessageStream,
} from "../llm/provider.js";
import {
agentTrajectoryHeaders,
beginAgentModelCall,
} from "../llm/agent-trajectory.js";
/**
* The single Pi transport boundary used by both conversational and worker
* agents. Pi keeps native tool calls; InkOS adds context guards, trajectory
* headers, cancellation, and stream deadlines around the request.
*/
export function guardedPiStream<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStream {
const reservedOutputTokens = Number.isFinite(options?.maxTokens)
? options!.maxTokens!
: Number.isFinite(model.maxTokens)
? model.maxTokens
: 4096;
assertWithinContextWindow({
piModel: model,
model: model.id,
estimatedInputTokens: estimatePiContextTokens(context),
reservedOutputTokens,
});
const modelCall = beginAgentModelCall();
const traceHeaders = agentTrajectoryHeaders(model.baseUrl, modelCall, 1, {
effort: String(options?.reasoning ?? (model.reasoning ? "enabled" : "disabled")),
});
return guardAssistantMessageStream(
model,
(signal) => streamSimple(model, context, {
...options,
headers: { ...(options?.headers ?? {}), ...traceHeaders },
signal,
}),
options?.signal,
);
}
+95
View File
@@ -1,4 +1,5 @@
import { Agent } from "@mariozechner/pi-agent-core";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import {
createAssistantMessageEventStream,
type Api,
@@ -9,6 +10,8 @@ import {
type Provider,
type SimpleStreamOptions,
} from "@mariozechner/pi-ai";
import type { Static, TSchema } from "@sinclair/typebox";
import { Value } from "@sinclair/typebox/value";
import {
chatCompletion,
type LLMClient,
@@ -16,6 +19,8 @@ import {
type LLMResponse,
type OnStreamProgress,
} from "../llm/provider.js";
import { guardedPiStream } from "./pi-stream.js";
import { isLlmStubEnabled, stubChatCompletion } from "./llm-stub.js";
export interface WorkerAgentOptions {
readonly temperature?: number;
@@ -26,6 +31,13 @@ export interface WorkerAgentOptions {
readonly signal?: AbortSignal;
}
export interface WorkerResultTool<TParameters extends TSchema> {
readonly name: string;
readonly label: string;
readonly description: string;
readonly parameters: TParameters;
}
const EMPTY_COST = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
function workerModel(client: LLMClient, modelId: string, maxTokens?: number): Model<Api> {
@@ -91,6 +103,16 @@ function assistantMessage(
};
}
function localStopStream(model: Model<Api>) {
const stream = createAssistantMessageEventStream();
const message = assistantMessage(model, "", undefined, "stop");
queueMicrotask(() => {
stream.push({ type: "done", reason: "stop", message });
stream.end(message);
});
return stream;
}
function textFromContent(content: Message["content"]): string {
if (typeof content === "string") return content;
return content
@@ -273,3 +295,76 @@ export async function runWorkerAgent(
options.signal?.removeEventListener("abort", abortAgent);
}
}
/**
* Run a worker whose result is host-consumed state rather than prose.
* The model must submit validated arguments through one Pi tool; the host owns
* the tool result and never scrapes JSON out of assistant text.
*/
export async function runWorkerAgentTool<TParameters extends TSchema>(
client: LLMClient,
modelId: string,
messages: ReadonlyArray<LLMMessage>,
resultTool: WorkerResultTool<TParameters>,
options: WorkerAgentOptions = {},
): Promise<Static<TParameters>> {
options.signal?.throwIfAborted();
if (isLlmStubEnabled()) {
const response = stubChatCompletion(messages, modelId);
return Value.Parse(resultTool.parameters, JSON.parse(response.content)) as Static<TParameters>;
}
if (!client._piModel) {
throw new Error("Structured worker tools require a resolved Pi model");
}
const model = workerModel(client, modelId, options.maxTokens);
const systemPrompt = [
...messages.filter((message) => message.role === "system").map((message) => message.content),
`Finish by calling ${resultTool.name} exactly once. Do not print the result as prose or JSON.`,
].join("\n\n");
const promptMessages = toAgentMessages(messages, model);
if (promptMessages.length === 0) {
throw new Error("Structured Worker Agent requires at least one non-system message");
}
let submitted: Static<TParameters> | undefined;
const tool: AgentTool<TParameters, Static<TParameters>> = {
...resultTool,
execute: async (_toolCallId, params): Promise<AgentToolResult<Static<TParameters>>> => {
submitted = params;
return {
content: [{ type: "text", text: "Structured result accepted by the host." }],
details: params,
};
},
};
const agent = new Agent({
initialState: { model, systemPrompt, tools: [tool], messages: [] },
toolExecution: "sequential",
streamFn: (streamModel, context, streamOptions) => submitted
? localStopStream(streamModel)
: guardedPiStream(streamModel, context, {
...streamOptions,
...(options.temperature !== undefined ? { temperature: options.temperature } : {}),
...(options.maxTokens !== undefined ? { maxTokens: options.maxTokens } : {}),
signal: combineSignals(streamOptions?.signal, options.signal),
}),
getApiKey: () => client._apiKey,
});
const abortAgent = () => agent.abort();
options.signal?.addEventListener("abort", abortAgent, { once: true });
try {
await agent.prompt(promptMessages);
options.signal?.throwIfAborted();
if (!submitted) {
await agent.prompt(`You did not call ${resultTool.name}. Call it now with the complete result.`);
options.signal?.throwIfAborted();
}
if (!submitted) {
throw new Error(`Worker Agent completed without calling ${resultTool.name}`);
}
return submitted;
} finally {
options.signal?.removeEventListener("abort", abortAgent);
}
}
+19 -1
View File
@@ -1,5 +1,6 @@
import type { LLMClient, LLMMessage, LLMResponse, OnStreamProgress } from "../llm/provider.js";
import { runWorkerAgent } from "../agent/worker-agent.js";
import { runWorkerAgent, runWorkerAgentTool, type WorkerResultTool } from "../agent/worker-agent.js";
import type { Static, TSchema } from "@sinclair/typebox";
import { appendPromptPackGuidance } from "../prompts/prompt-pack.js";
import { searchWeb, fetchUrl } from "../utils/web-search.js";
import type { Logger } from "../utils/logger.js";
@@ -41,6 +42,23 @@ export abstract class BaseAgent {
});
}
protected async submitStructured<TParameters extends TSchema>(
messages: ReadonlyArray<LLMMessage>,
resultTool: WorkerResultTool<TParameters>,
options?: { readonly temperature?: number; readonly maxTokens?: number },
): Promise<Static<TParameters>> {
return runWorkerAgentTool(
this.ctx.client,
this.ctx.model,
appendActivatedSkillGuidance(messages, this.ctx.activatedSkills),
resultTool,
{
...options,
signal: this.ctx.signal,
},
);
}
protected async withPromptPackGuidance(basePrompt: string, promptId: string): Promise<string> {
return appendPromptPackGuidance(basePrompt, {
promptId,
@@ -12,6 +12,13 @@ export interface FoundationReviewResult {
readonly overallFeedback: string;
}
export class FoundationReviewParseError extends Error {
constructor(readonly missingDimensions: ReadonlyArray<number>) {
super(`Foundation review output is missing dimension${missingDimensions.length === 1 ? "" : "s"}: ${missingDimensions.join(", ")}`);
this.name = "FoundationReviewParseError";
}
}
const PASS_THRESHOLD = 80;
const DIMENSION_FLOOR = 60;
@@ -181,19 +188,28 @@ Be strict. 80 means "ready to write without changes."`;
dimensions: ReadonlyArray<string>,
): FoundationReviewResult {
const parsedDimensions: Array<{ readonly name: string; readonly score: number; readonly feedback: string }> = [];
const missingDimensions: number[] = [];
for (let i = 0; i < dimensions.length; i++) {
const regex = new RegExp(
`=== DIMENSION: ${i + 1} ===\\s*[\\s\\S]*?(?:分数|Score)[:]\\s*(\\d+)[\\s\\S]*?(?:意见|Feedback)[:]\\s*([\\s\\S]*?)(?==== |$)`,
);
const match = content.match(regex);
if (!match) {
missingDimensions.push(i + 1);
continue;
}
parsedDimensions.push({
name: dimensions[i]!,
score: match ? parseInt(match[1]!, 10) : 50,
feedback: match ? match[2]!.trim() : "(parse failed)",
score: parseInt(match[1]!, 10),
feedback: match[2]!.trim(),
});
}
if (missingDimensions.length > 0) {
throw new FoundationReviewParseError(missingDimensions);
}
const totalScore = parsedDimensions.length > 0
? Math.round(parsedDimensions.reduce((sum, d) => sum + d.score, 0) / parsedDimensions.length)
: 0;
+14 -21
View File
@@ -31,10 +31,6 @@ export async function readEmotionalArcs(storyDir: string): Promise<string> {
return readOrEmpty(join(storyDir, "emotional_arcs.md"));
}
export async function readPendingHooks(storyDir: string): Promise<string> {
return readOrEmpty(join(storyDir, "pending_hooks.md"));
}
export async function readBrief(storyDir: string): Promise<string> {
return readOrEmpty(join(storyDir, "brief.md"));
}
@@ -237,25 +233,22 @@ function extractRowsByRelation(
return rows.map((row) => `| ${row.join(" | ")} |`).join("\n");
}
const RELEVANT_THREAD_STATUS_PATTERN = /activat|partial_payoff|推进|高压|open|progress/i;
const STALE_STATUS_PATTERN = /resolved|deferred|dormant|暂稳待续|暂挂|已回收/i;
export function extractRelevantThreads(pendingHooksRaw: string, subplotBoardRaw: string): string {
const hookRows = parseMarkdownTableRows(pendingHooksRaw)
.filter((row) => !/^(hook_id)$/i.test(row[0] ?? ""))
.filter((row) => row.some((cell) => RELEVANT_THREAD_STATUS_PATTERN.test(cell)))
.filter((row) => !row.some((cell) => STALE_STATUS_PATTERN.test(cell)))
.map((row) => `- ${row[0]}: ${row.slice(1).filter(Boolean).join(" | ")}`);
const subplotRows = parseMarkdownTableRows(subplotBoardRaw)
.filter((row) => !/^(id|subplot_id|subplot)$/i.test(row[0] ?? ""))
.filter((row) => row.some((cell) => RELEVANT_THREAD_STATUS_PATTERN.test(cell)))
.filter((row) => !row.some((cell) => STALE_STATUS_PATTERN.test(cell)))
.map((row) => `- ${row[0]}: ${row.slice(1).filter(Boolean).join(" | ")}`);
export function formatRelevantThreads(
hooks: ReadonlyArray<StoredHook>,
subplotBoardRaw: string,
language: "zh" | "en" = "zh",
): string {
const hookRows = hooks.map((hook) => `- ${hook.hookId}: ${[
hook.type,
hook.status,
hook.expectedPayoff,
hook.payoffTiming,
hook.notes,
].filter(Boolean).join(" | ")}`);
const subplotRows = extractActiveSubplotLines(subplotBoardRaw).map((line) => `- ${line}`);
const lines = [...hookRows, ...subplotRows];
if (lines.length === 0) {
return "(暂无活跃线索)";
return language === "en" ? "(no relevant threads)" : "(暂无相关线索)";
}
return lines.join("\n");
}
+5 -5
View File
@@ -26,13 +26,12 @@ import {
extractCollaboratorRows,
extractOpponentRows,
extractProtagonistRow,
extractRelevantThreads,
formatRelevantThreads,
formatRecentSummaries,
formatRecyclableHooks,
readBookRules,
readCharacterMatrix,
readEmotionalArcs,
readPendingHooks,
readSubplotBoard,
} from "./planner-context.js";
import type { StoredHook } from "../state/memory-db.js";
@@ -140,6 +139,7 @@ export class PlannerAgent extends BaseAgent {
previousEndingExcerpt: seedMaterials.previousEndingExcerpt,
brief: seedMaterials.brief,
chapterContext: input.externalContext,
relevantHooks: memorySelection.hooks,
recyclableHooks: memorySelection.recyclableHooks,
// Phase hotfix 4: thread book language through so the planner uses
// English prompts (system + user template + golden opening guidance)
@@ -187,14 +187,14 @@ export class PlannerAgent extends BaseAgent {
readonly previousEndingExcerpt?: string;
readonly brief?: string;
readonly chapterContext?: string;
readonly relevantHooks?: ReadonlyArray<StoredHook>;
readonly recyclableHooks?: ReadonlyArray<StoredHook>;
readonly language?: "zh" | "en";
}): Promise<ChapterMemo> {
const [characterMatrix, subplotBoard, emotionalArcs, pendingHooks, bookRulesRaw] = await Promise.all([
const [characterMatrix, subplotBoard, emotionalArcs, bookRulesRaw] = await Promise.all([
readCharacterMatrix(input.storyDir),
readSubplotBoard(input.storyDir),
readEmotionalArcs(input.storyDir),
readPendingHooks(input.storyDir),
readBookRules(input.storyDir),
]);
@@ -222,7 +222,7 @@ export class PlannerAgent extends BaseAgent {
protagonistMatrixRow: extractProtagonistRow(characterMatrix),
opponentRows: extractOpponentRows(characterMatrix, 3),
collaboratorRows: extractCollaboratorRows(characterMatrix, 3),
relevantThreads: extractRelevantThreads(pendingHooks, subplotBoard),
relevantThreads: formatRelevantThreads(input.relevantHooks ?? [], subplotBoard, language),
recyclableHooks: formatRecyclableHooks(
input.recyclableHooks ?? [],
input.chapterNumber,
+35 -123
View File
@@ -11,7 +11,6 @@ import { filterSummaries } from "../utils/context-filter.js";
import {
buildGovernedCharacterMatrixWorkingSet,
buildGovernedHookWorkingSet,
mergeTableMarkdownByKey,
} from "../utils/governed-working-set.js";
import { applySpotFixPatches, parseSpotFixPatches } from "../utils/spot-fix-patches.js";
import {
@@ -37,9 +36,6 @@ export interface ReviseOutput {
readonly revisedContent: string;
readonly wordCount: number;
readonly fixedIssues: ReadonlyArray<string>;
readonly updatedState: string;
readonly updatedLedger: string;
readonly updatedHooks: string;
readonly tokenUsage?: {
readonly promptTokens: number;
readonly completionTokens: number;
@@ -127,19 +123,25 @@ export class ReviserAgent extends BaseAgent {
contextPackage?: ContextPackage;
ruleStack?: RuleStack;
lengthSpec?: LengthSpec;
baselineChapter?: number;
},
): Promise<ReviseOutput> {
const baselineStoryDir = options?.baselineChapter === undefined
? join(bookDir, "story")
: join(bookDir, "story", "snapshots", String(options.baselineChapter));
const [currentState, ledger, hooks, styleGuideRaw, volumeOutline, storyBible, characterMatrix, chapterSummaries, parentCanon, fanficCanon] = await Promise.all([
// Phase 5 consolidation: derive initial state from roles + seed hooks
// when current_state.md is still the architect seed placeholder.
readCurrentStateWithFallback(bookDir, "(文件不存在)"),
this.readFileSafe(join(bookDir, "story/particle_ledger.md")),
this.readFileSafe(join(bookDir, "story/pending_hooks.md")),
options?.baselineChapter === undefined
? readCurrentStateWithFallback(bookDir, "(文件不存在)")
: this.readFileSafe(join(baselineStoryDir, "current_state.md")),
this.readFileSafe(join(baselineStoryDir, "particle_ledger.md")),
this.readFileSafe(join(baselineStoryDir, "pending_hooks.md")),
this.readFileSafe(join(bookDir, "story/style_guide.md")),
readVolumeMap(bookDir, "(文件不存在)"),
readStoryFrame(bookDir, "(文件不存在)"),
readCharacterContext(bookDir, "(文件不存在)"),
this.readFileSafe(join(bookDir, "story/chapter_summaries.md")),
options?.baselineChapter === undefined
? readCharacterContext(bookDir, "(文件不存在)")
: this.readSnapshotCharacterContext(bookDir, baselineStoryDir),
this.readFileSafe(join(baselineStoryDir, "chapter_summaries.md")),
this.readFileSafe(join(bookDir, "story/parent_canon.md")),
this.readFileSafe(join(bookDir, "story/fanfic_canon.md")),
]);
@@ -190,7 +192,7 @@ export class ReviserAgent extends BaseAgent {
: "\n8. 保持章节字数在目标区间内;只有在修复关键问题确实需要时才允许轻微偏离")
: "";
const langPrefix = isEnglish
? `【LANGUAGE OVERRIDE】ALL output (FIXED_ISSUES, PATCHES, REVISED_CONTENT, UPDATED_STATE, UPDATED_HOOKS) MUST be in English.\n\n`
? `【LANGUAGE OVERRIDE】ALL output (FIXED_ISSUES, PATCHES, REVISED_CONTENT) MUST be in English.\n\n`
: "";
const governedMode = Boolean(options?.chapterIntent && options?.contextPackage && options?.ruleStack);
const hooksWorkingSet = governedMode && options?.contextPackage
@@ -287,26 +289,18 @@ ${chapterContent}`;
const output = this.parseOutput(
response.content,
gp,
mode,
chapterContent,
autoOutputMode,
);
const mergedOutput = governedMode
? {
...output,
updatedHooks: mergeTableMarkdownByKey(hooks, output.updatedHooks, [0]),
}
: output;
const wordCount = options?.lengthSpec
? countChapterLength(mergedOutput.revisedContent, options.lengthSpec.countingMode)
: mergedOutput.wordCount;
return { ...mergedOutput, wordCount, tokenUsage: response.usage };
? countChapterLength(output.revisedContent, options.lengthSpec.countingMode)
: output.wordCount;
return { ...output, wordCount, tokenUsage: response.usage };
}
private parseOutput(
content: string,
gp: GenreProfile,
mode: ReviseMode,
originalChapter: string,
autoOutputMode: AutoOutputMode = "allow-full",
@@ -329,15 +323,10 @@ ${chapterContent}`;
revisedContent,
wordCount: revisedContent.length,
fixedIssues: applied ? fixedIssues : [],
updatedState: extract("UPDATED_STATE") || "(状态卡未更新)",
updatedLedger: gp.numericalSystem
? (extract("UPDATED_LEDGER") || "(账本未更新)")
: "",
updatedHooks: extract("UPDATED_HOOKS") || "(伏笔池未更新)",
});
// Auto mode: route by issue type — structural issues require REVISED_CONTENT,
// local-only issues only accept PATCHES, mixed sets accept either.
// Auto mode obeys the auditor's structured repair scope. It never infers
// semantic intent from issue prose.
if (mode === "auto") {
if (autoOutputMode === "patch-only") {
const patchesRaw = extract("PATCHES");
@@ -406,9 +395,6 @@ ${chapterContent}`;
const { langPrefix, gp, protagonistBlock, numericalRule, resolvedLanguage, lengthSpec, autoOutputMode } = params;
// lengthGuardrail intentionally not used in auto mode — length constraint is embedded in REVISED_CONTENT description
const en = resolvedLanguage === "en";
const ledgerSection = gp.numericalSystem
? (en ? "\n=== UPDATED_LEDGER ===\n(Full updated resource ledger)" : "\n=== UPDATED_LEDGER ===\n(更新后的完整资源账本)")
: "";
const rewriteLengthConstraint = lengthSpec
? (en
? `\n HARD CONSTRAINT: The revised chapter must stay within ${lengthSpec.softMin}-${lengthSpec.softMax} characters (target: ${lengthSpec.target}, ±25%). This is non-negotiable — do not exceed this range.`
@@ -468,13 +454,7 @@ REPLACEMENT_TEXT:
--- END PATCH ---
=== REVISED_CONTENT ===
(Full revised chapter content only when PATCHES cannot solve the problem. Omit this section if using PATCHES)
=== UPDATED_STATE ===
(Full updated state card)
${ledgerSection}
=== UPDATED_HOOKS ===
(Full updated hooks board)`
(Full revised chapter content only when PATCHES cannot solve the problem. Omit this section if using PATCHES)`
: `${langPrefix}你是一位专业的${gp.name}网络小说修稿编辑。你的任务是根据审稿意见对章节进行修正。${protagonistBlock}${routingDirectiveZh}
PATCHES REVISED_CONTENT
@@ -516,13 +496,7 @@ REPLACEMENT_TEXT:
--- END PATCH ---
=== REVISED_CONTENT ===
(//)
=== UPDATED_STATE ===
()
${ledgerSection}
=== UPDATED_HOOKS ===
()`;
(//)`;
}
private buildLegacySystemPrompt(params: {
@@ -546,24 +520,12 @@ TARGET_TEXT:
()
REPLACEMENT_TEXT:
()
--- END PATCH ---
=== UPDATED_STATE ===
()
${gp.numericalSystem ? "\n=== UPDATED_LEDGER ===\n(更新后的完整资源账本)" : ""}
=== UPDATED_HOOKS ===
()`
--- END PATCH ---`
: `=== FIXED_ISSUES ===
()
=== REVISED_CONTENT ===
()
=== UPDATED_STATE ===
()
${gp.numericalSystem ? "\n=== UPDATED_LEDGER ===\n(更新后的完整资源账本)" : ""}
=== UPDATED_HOOKS ===
()`;
()`;
return `${langPrefix}你是一位专业的${gp.name}网络小说修稿编辑。你的任务是根据审稿意见对章节进行修正。${protagonistBlock}
@@ -572,10 +534,9 @@ ${gp.numericalSystem ? "\n=== UPDATED_LEDGER ===\n(更新后的完整资源账
稿
1.
2. ${numericalRule}
4.
4. 宿
5.
6.
7. ${gp.numericalSystem ? "、账本" : ""}
${lengthGuardrail}
${mode === "spot-fix" ? "\n9. spot-fix 只能输出局部补丁,禁止输出整章改写;TARGET_TEXT 必须能在原文中唯一命中\n10. 如果需要大面积改写,说明无法安全 spot-fix,并让 PATCHES 留空" : ""}
@@ -592,6 +553,15 @@ ${outputFormat}`;
}
}
private async readSnapshotCharacterContext(
bookDir: string,
snapshotStoryDir: string,
): Promise<string> {
const snapshotMatrix = await this.readFileSafe(join(snapshotStoryDir, "character_matrix.md"));
if (snapshotMatrix !== "(文件不存在)") return snapshotMatrix;
return readCharacterContext(bookDir, "(文件不存在)");
}
private buildReducedControlBlock(
memo: ChapterMemo | undefined,
intent: ChapterIntent | undefined,
@@ -629,38 +599,6 @@ ${overrides}\n`;
}
}
// Local-only categories: reviser produces line/paragraph patches. Fixing these
// with a full rewrite risks introducing new issues, so we force patch-only.
const LOCAL_ONLY_PATTERNS: ReadonlyArray<RegExp> = [
/Paragraph uniformity|段落等长/i,
/Hedge density|套话密度/i,
/Formulaic transitions|公式化转折/i,
/List-like structure|列表式结构/i,
/Cross-chapter repetition|跨章重复/i,
/AI-tell word density/i,
/Fatigue word|高疲劳词/i,
/Information Boundary Check|信息越界/i,
/Knowledge Base Pollution|知识库污染/i,
];
// Structural/semantic categories: character collapse, mainline drift, conflict
// absence, timeline breaks, unpaid hooks, memo drift. These cannot be patched;
// the reviser must rewrite the chapter in full.
const STRUCTURAL_PATTERNS: ReadonlyArray<RegExp> = [
/OOC|人设|Character Fidelity|Character Matrix|Character.*Consistency/i,
/Mainline.*Drift|主线偏离|Outline Drift|大纲偏离|Chapter Memo Drift|章节备忘偏离/i,
/Conflict|冲突乏力|Payoff Dilution|爽点虚化/i,
/Timeline|时间线/i,
/Hook Check|伏笔检查|Hook.*Debt|伏笔.*债|未兑现/i,
/Power Scaling|战力崩坏|金手指/i,
/Pacing|节奏/i,
/POV Consistency|视角/i,
/Subplot Stagnation|支线停滞|Arc Flatline|弧线平坦/i,
/Relationship Dynamics|关系动态|情感表达/i,
/Incentive Chain|利益链/i,
/Canon Event|正典|Mainline Canon/i,
];
function resolveAutoOutputMode(issues: ReadonlyArray<AuditIssue>): AutoOutputMode {
if (issues.length === 0) {
return "allow-full";
@@ -678,37 +616,11 @@ function resolveAutoOutputMode(issues: ReadonlyArray<AuditIssue>): AutoOutputMod
}
}
const isStructural = (issue: AuditIssue): boolean => {
const text = `${issue.category} ${issue.description}`;
return STRUCTURAL_PATTERNS.some((pattern) => pattern.test(text));
};
const isLocal = (issue: AuditIssue): boolean => {
const text = `${issue.category} ${issue.description}`;
return LOCAL_ONLY_PATTERNS.some((pattern) => pattern.test(text));
};
// Count blocking (critical + warning) structural vs local issues. Info-level
// findings are reviewer hints for the Polisher — they do not drive routing.
const blocking = issues.filter((issue) => issue.severity !== "info");
if (blocking.length === 0) {
return "patch-only"; // only hints / info — at most local polish
}
const structuralCount = blocking.filter(isStructural).length;
const localOnlyCount = blocking.filter(isLocal).length;
// Any structural issue forces a rewrite — patches cannot fix character
// collapse, mainline drift, missing payoff, or timeline breaks.
if (structuralCount > 0) {
return "rewrite-only";
}
// All blocking issues are in the local-only list → safe to patch.
if (localOnlyCount === blocking.length) {
return "patch-only";
}
// Mixed / unknown blocking issue set — let the reviser pick (usually ends
// up rewriting when critical, patching when warning).
// Unknown scope is intentionally not guessed from natural-language labels.
// The reviser may choose the safest representation from the actual issue text.
return "allow-full";
}
+27 -3
View File
@@ -28,6 +28,12 @@ export interface StoryboardCreationInput {
readonly granularity?: string;
readonly maxShots?: number;
readonly language?: "zh" | "en";
readonly segment?: {
readonly label: string;
readonly index: number;
readonly count: number;
readonly estimatedShots: number;
};
}
export interface InteractiveFilmCreationInput {
@@ -393,6 +399,11 @@ function buildStoryboardCreationUserPrompt(input: StoryboardCreationInput, langu
"## Full Source Material",
input.sourceText?.trim()
|| "The user did not provide full source material; write an extensible storyboard draft strictly from the storyboard spec and user requirements.",
...(input.segment ? [
"",
"## Current Production Segment",
`Write only ${input.segment.label} (${input.segment.index + 1}/${input.segment.count}) in this call. The global shot cap is NOT the shot count for this call. Preserve all global requirements and follow the exact scene/segment shot count when the user confirmed one. Do not summarize or write any other segment.`,
] : []),
"",
"## Output Format",
`# ${input.title} Storyboard`,
@@ -412,6 +423,11 @@ function buildStoryboardCreationUserPrompt(input: StoryboardCreationInput, langu
"",
"## 完整源素材",
input.sourceText?.trim() || "用户没有提供完整源素材;请严格根据分镜规格和用户要求写一个可继续扩展的分镜稿。",
...(input.segment ? [
"",
"## 当前生产分段",
`本次只写${input.segment.label}${input.segment.index + 1}/${input.segment.count})。全局镜头上限不是本次镜头数。保留全部全局要求;用户已确认本场/本段镜头数时严格按该数量执行。不要概括或生成任何其他分段。`,
] : []),
"",
"## 输出格式",
`# ${input.title} 分镜`,
@@ -544,8 +560,10 @@ function estimateScriptMaxTokens(input: ScriptCreationInput): number {
}
function estimateStoryboardMaxTokens(input: StoryboardCreationInput): number {
const shots = input.maxShots ?? 24;
return Math.min(24000, Math.max(10000, shots * 700));
const shots = input.segment?.estimatedShots ?? input.maxShots ?? 24;
// Each shot includes both an editable shot record and a standalone image
// prompt. The old 700-token estimate cut off complete 13-shot scenes.
return Math.min(48000, Math.max(12000, shots * 1800));
}
function estimateInteractiveFilmMaxTokens(input: InteractiveFilmCreationInput): number {
@@ -557,7 +575,11 @@ function extractPromptLines(markdown: string): string[] {
const prompts: string[] = [];
let promptColumnIndex = -1;
for (const rawLine of markdown.split(/\r?\n/)) {
const line = rawLine.trim();
const line = rawLine
.trim()
.replace(/^`{1,3}\s*/u, "")
.replace(/\s*`{1,3}$/u, "")
.trim();
if (!line) {
promptColumnIndex = -1;
continue;
@@ -603,6 +625,8 @@ function isPromptColumnHeader(cell: string): boolean {
function cleanPromptText(text: string): string {
return text
.replace(/^`{1,3}\s*/u, "")
.replace(/\s*`{1,3}$/u, "")
.replace(/\s*\|\s*$/u, "")
.replace(/\*\*$/u, "")
.replace(/^(?:Prompt(?:\s+for\s+[^:*]+)?|(?:\s*[^:*]+)?||)\s*[:]\s*/iu, "")
+6 -4
View File
@@ -23,7 +23,9 @@ export function buildSettlerSystemPrompt(
- ****"最近推进"
- "已回收"
- 线"延后"
- brand-new unresolved thread hookId newHookCandidates hook hook
- hookId hookOps.upsert
- 使// hookId
- newHookCandidates 宿
- payoffTiming 使 immediate / near-term / mid-arc / slow-burn / endgame
- **** hook mention `;
@@ -151,8 +153,8 @@ function buildSettlerOutputFormat(gp: GenreProfile): string {
1. truth files
2.
3. hookOps.upsert hookId hookId
4. brand-new unresolved thread newHookCandidates hookId
3. hookOps.upsert hookId hookId id
4. brand-new unresolved thread newHookCandidates
5. hook mention lastAdvancedChapter
6. hooklastAdvancedChapter
7. hook resolve / defer
@@ -221,7 +223,7 @@ ${controlBlock}
##
${params.currentState}
${ledgerBlock}
##
##
${params.hooks}
${selectedEvidenceBlock}${summariesBlock}${subplotBlock}${emotionalBlock}${matrixBlock}
${outlineBlock}
+2 -2
View File
@@ -120,7 +120,7 @@ export class ShortFictionOutlineAgent extends BaseAgent {
this.chat([
{ role: "system", content: buildShortFictionOutlineSystemPrompt(input.language) },
{ role: "user", content: buildShortFictionOutlineUserPrompt(input, input.language) },
], { temperature: 0.55, maxTokens: 8192 }), this.name, this.log);
], { temperature: 0.55, maxTokens: 16_384 }), this.name, this.log);
return parseShortFictionOutline(response.content, input.language);
}
@@ -154,7 +154,7 @@ export class ShortFictionOutlineReviserAgent extends BaseAgent {
{ role: "user", content: buildShortFictionOutlineUserPrompt(input, input.language) },
{ role: "assistant", content: input.outline.rawContent.trim() },
{ role: "user", content: buildShortFictionOutlineRevisionFollowup(input, input.language) },
], { temperature: 0.45, maxTokens: 8192 }), this.name, this.log);
], { temperature: 0.45, maxTokens: 16_384 }), this.name, this.log);
return parseShortFictionOutline(response.content, input.language);
}
+61 -10
View File
@@ -37,7 +37,12 @@ import {
} from "../utils/governed-working-set.js";
import { extractPOVFromOutline, filterMatrixByPOV, filterHooksByPOV } from "../utils/pov-filter.js";
import { parseCreativeOutput } from "./writer-parser.js";
import { buildRuntimeStateArtifacts, type RuntimeStateArtifacts } from "../state/runtime-state-store.js";
import {
buildRuntimeStateArtifacts,
buildRuntimeStateArtifactsFromSnapshot,
loadRuntimeStateSnapshotAtChapter,
type RuntimeStateArtifacts,
} from "../state/runtime-state-store.js";
import type { RuntimeStateSnapshot } from "../state/state-reducer.js";
import { parsePendingHooksMarkdown } from "../utils/memory-retrieval.js";
import { analyzeHookHealth } from "../utils/hook-health.js";
@@ -93,6 +98,8 @@ export interface SettleChapterStateInput {
readonly title: string;
readonly content: string;
readonly allowReapply?: boolean;
readonly allowNewHooks?: boolean;
readonly baselineChapter?: number;
readonly chapterIntent?: string;
readonly contextPackage?: ContextPackage;
readonly ruleStack?: RuleStack;
@@ -455,6 +462,9 @@ export class WriterAgent extends BaseAgent {
}
async settleChapterState(input: SettleChapterStateInput): Promise<WriteChapterOutput> {
const baselineStoryDir = input.baselineChapter === undefined
? join(input.bookDir, "story")
: join(input.bookDir, "story", "snapshots", String(input.baselineChapter));
const [
currentState,
ledger,
@@ -465,14 +475,17 @@ export class WriterAgent extends BaseAgent {
characterMatrix,
volumeOutline,
] = await Promise.all([
// Phase 5 consolidation fallback: derive initial state when only seed on disk.
readCurrentStateWithFallback(input.bookDir, "(文件尚未创建)"),
this.readFileOrDefault(join(input.bookDir, "story/particle_ledger.md")),
this.readFileOrDefault(join(input.bookDir, "story/pending_hooks.md")),
this.readFileOrDefault(join(input.bookDir, "story/chapter_summaries.md")),
this.readFileOrDefault(join(input.bookDir, "story/subplot_board.md")),
this.readFileOrDefault(join(input.bookDir, "story/emotional_arcs.md")),
readCharacterContext(input.bookDir, "(文件尚未创建)"),
input.baselineChapter === undefined
? readCurrentStateWithFallback(input.bookDir, "(文件尚未创建)")
: this.readFileOrDefault(join(baselineStoryDir, "current_state.md")),
this.readFileOrDefault(join(baselineStoryDir, "particle_ledger.md")),
this.readFileOrDefault(join(baselineStoryDir, "pending_hooks.md")),
this.readFileOrDefault(join(baselineStoryDir, "chapter_summaries.md")),
this.readFileOrDefault(join(baselineStoryDir, "subplot_board.md")),
this.readFileOrDefault(join(baselineStoryDir, "emotional_arcs.md")),
input.baselineChapter === undefined
? readCharacterContext(input.bookDir, "(文件尚未创建)")
: this.readSnapshotCharacterContext(input.bookDir, baselineStoryDir),
readVolumeMap(input.bookDir, "(文件尚未创建)"),
]);
@@ -518,6 +531,8 @@ export class WriterAgent extends BaseAgent {
resolvedLanguage,
input.chapterNumber,
input.allowReapply,
input.baselineChapter,
input.allowNewHooks,
);
return {
@@ -738,6 +753,16 @@ export class WriterAgent extends BaseAgent {
});
}
if (output.updatedSubplots) {
writes.push({ relativePath: join("story", "subplot_board.md"), content: output.updatedSubplots });
}
if (output.updatedEmotionalArcs) {
writes.push({ relativePath: join("story", "emotional_arcs.md"), content: output.updatedEmotionalArcs });
}
if (output.updatedCharacterMatrix) {
writes.push({ relativePath: join("story", "character_matrix.md"), content: output.updatedCharacterMatrix });
}
const runtimeStateSnapshot = runtimeStateArtifacts?.snapshot ?? output.runtimeStateSnapshot;
if (runtimeStateSnapshot) {
writes.push(
@@ -1150,6 +1175,15 @@ ${overrides}\n`;
}
}
private async readSnapshotCharacterContext(
bookDir: string,
snapshotStoryDir: string,
): Promise<string> {
const snapshotMatrix = await this.readFileOrDefault(join(snapshotStoryDir, "character_matrix.md"));
if (snapshotMatrix !== "(文件尚未创建)") return snapshotMatrix;
return readCharacterContext(bookDir, "(文件尚未创建)");
}
/** Save new truth files (summaries, subplots, emotional arcs, character matrix). */
async saveNewTruthFiles(
bookDir: string,
@@ -1261,16 +1295,33 @@ ${overrides}\n`;
language: "zh" | "en",
authoritativeChapterNumber?: number,
allowReapply?: boolean,
baselineChapter?: number,
allowNewHooks?: boolean,
): Promise<RuntimeStateArtifacts | null> {
if (!delta) return null;
const safeDelta = authoritativeChapterNumber === undefined
? delta
: this.normalizeRuntimeStateDeltaChapter(delta, authoritativeChapterNumber);
return buildRuntimeStateArtifacts({
if (baselineChapter === undefined) {
return buildRuntimeStateArtifacts({
bookDir,
delta: safeDelta,
language,
allowReapply,
allowNewHooks,
});
}
const snapshot = await loadRuntimeStateSnapshotAtChapter({
bookDir,
chapterNumber: baselineChapter,
language,
});
return buildRuntimeStateArtifactsFromSnapshot({
snapshot,
delta: safeDelta,
language,
allowReapply,
allowNewHooks,
});
}
+4 -6
View File
@@ -240,7 +240,10 @@ export {
ActionSourceSchema,
ActionPayloadSchema,
CreateBookActionPayloadSchema,
ContinuationImportActionPayloadSchema,
FanficCreateActionPayloadSchema,
GenerateCoverActionPayloadSchema,
ImitationCreateActionPayloadSchema,
InteractiveFilmCreateActionPayloadSchema,
PlayStartActionPayloadSchema,
RequestedIntentSchema,
@@ -248,6 +251,7 @@ export {
ScriptCreateActionPayloadSchema,
ScriptTargetFormatSchema,
ShortRunActionPayloadSchema,
SpinoffCreateActionPayloadSchema,
StoryboardCreateActionPayloadSchema,
WriteNextActionPayloadSchema,
type ActionSource,
@@ -672,8 +676,6 @@ export {
} from "./interactive-film/graph-store.js";
export {
generateStoryGraph,
buildStoryGraphFromLLMText,
extractJson,
type GenerateStoryGraphInput,
} from "./interactive-film/generate.js";
export {
@@ -705,10 +707,6 @@ export {
buildUpsertCharactersDelta,
} from "./interactive-film/authoring-tools.js";
export { writeCharacterFacts, readCharacterVoices } from "./interactive-film/memory-link.js";
export {
buildFillNodeDeltaFromLLMText,
buildStructureDeltaFromLLMText,
} from "./interactive-film/authoring-generate.js";
export { summarizeStoryGraph, buildFilmAuthoringContext } from "./interactive-film/film-context.js";
export {
generateNodeImage,
@@ -75,6 +75,7 @@ export function shortRunCharsPerChapterError(value: number, language: "zh" | "en
// 在确认卡阶段就被拒绝,而不是任务开跑后才在 runner 里抛错;language 缺省时维持
// 600-1200 并集(此时最终语言由会话默认决定,envelope 层无法预知)。
export const ShortRunActionPayloadSchema = z.object({
title: z.string().min(1).optional(),
direction: z.string().min(1).optional(),
reference: z.string().min(1).optional(),
storyId: z.string().min(1).optional(),
@@ -169,6 +170,62 @@ export const TranslationCreateActionPayloadSchema = z.object({
segmentMaxChars: z.number().int().min(1).optional(),
}).strict();
export const FanficCreateActionPayloadSchema = z.object({
title: z.string().min(1).optional(),
sourceText: z.string().min(1).optional(),
sourcePath: z.string().min(1).optional(),
sourceName: z.string().min(1).optional(),
mode: z.enum(["canon", "au", "ooc", "cp"]).optional(),
genre: z.string().min(1).optional(),
platform: z.enum(["tomato", "qidian", "feilu", "other"]).optional(),
language: z.enum(["zh", "en"]).optional(),
targetChapters: z.number().int().min(1).optional(),
chapterWordCount: z.number().int().min(1).optional(),
}).strict().refine(
(payload) => Boolean(payload.sourceText?.trim() || payload.sourcePath?.trim()),
{ message: "fanficCreate requires sourceText or sourcePath" },
);
export const ContinuationImportActionPayloadSchema = z.object({
bookId: z.string().min(1).optional(),
title: z.string().min(1).optional(),
sourcePath: z.string().min(1).optional(),
splitPattern: z.string().min(1).optional(),
resumeFrom: z.number().int().min(1).optional(),
genre: z.string().min(1).optional(),
platform: z.enum(["tomato", "qidian", "feilu", "other"]).optional(),
language: z.enum(["zh", "en"]).optional(),
targetChapters: z.number().int().min(1).optional(),
chapterWordCount: z.number().int().min(1).optional(),
}).strict();
export const SpinoffCreateActionPayloadSchema = z.object({
title: z.string().min(1).optional(),
parentBookId: z.string().min(1).optional(),
direction: z.string().min(1).optional(),
genre: z.string().min(1).optional(),
platform: z.enum(["tomato", "qidian", "feilu", "other"]).optional(),
language: z.enum(["zh", "en"]).optional(),
targetChapters: z.number().int().min(1).optional(),
chapterWordCount: z.number().int().min(1).optional(),
}).strict();
export const ImitationCreateActionPayloadSchema = z.object({
title: z.string().min(1).optional(),
referenceText: z.string().min(1).optional(),
referencePath: z.string().min(1).optional(),
storyIdea: z.string().min(1).optional(),
sourceName: z.string().min(1).optional(),
genre: z.string().min(1).optional(),
platform: z.enum(["tomato", "qidian", "feilu", "other"]).optional(),
language: z.enum(["zh", "en"]).optional(),
targetChapters: z.number().int().min(1).optional(),
chapterWordCount: z.number().int().min(1).optional(),
}).strict().refine(
(payload) => Boolean(payload.referenceText?.trim() || payload.referencePath?.trim()),
{ message: "imitationCreate requires referenceText or referencePath" },
);
export const ActionPayloadSchema = z.object({
createBook: CreateBookActionPayloadSchema.optional(),
writeNext: WriteNextActionPayloadSchema.optional(),
@@ -179,6 +236,10 @@ export const ActionPayloadSchema = z.object({
storyboardCreate: StoryboardCreateActionPayloadSchema.optional(),
interactiveFilmCreate: InteractiveFilmCreateActionPayloadSchema.optional(),
translationCreate: TranslationCreateActionPayloadSchema.optional(),
fanficCreate: FanficCreateActionPayloadSchema.optional(),
continuationImport: ContinuationImportActionPayloadSchema.optional(),
spinoffCreate: SpinoffCreateActionPayloadSchema.optional(),
imitationCreate: ImitationCreateActionPayloadSchema.optional(),
draftStructure: z.object({
projectId: z.string().min(1).optional(),
instruction: z.string().default(""),
@@ -1,17 +0,0 @@
import { extractJson } from "./generate.js";
import { StoryNodeSchema } from "./graph-schema.js";
import type { StoryGraphDelta } from "./delta.js";
export function buildFillNodeDeltaFromLLMText(text: string, nodeId: string): StoryGraphDelta {
const parsed = extractJson(text) as Record<string, unknown>;
const node = StoryNodeSchema.parse({ ...parsed, id: nodeId });
return { nodes: { upsert: [node], remove: [] }, notes: [] };
}
export function buildStructureDeltaFromLLMText(text: string): StoryGraphDelta {
const parsed = extractJson(text) as { nodes?: unknown[] };
const rawNodes = Array.isArray(parsed.nodes) ? parsed.nodes : [];
if (rawNodes.length === 0) throw new Error("draft_structure: LLM returned no nodes");
const nodes = rawNodes.map((n) => StoryNodeSchema.parse(n));
return { nodes: { upsert: nodes, remove: [] }, notes: [] };
}
+32 -31
View File
@@ -1,40 +1,16 @@
import type { LLMClient } from "../llm/provider.js";
import { runWorkerAgent } from "../agent/worker-agent.js";
import { runWorkerAgentTool } from "../agent/worker-agent.js";
import { appendActivatedSkillGuidance } from "../agents/base.js";
import type { ActivatedSkillGuidance } from "../agent/skill-tool.js";
import { StoryGraphSchema, type StoryGraph } from "./graph-schema.js";
const GRAPH_JSON_SHAPE = `{"schemaVersion":1,"projectId":"","title":"","variables":[{"name":"","type":"flag|counter|relationship|item","default":0,"desc":""}],"nodes":[{"id":"","title":"","type":"start|normal|branch|ending","sceneDesc":"","dialogue":[{"speaker":"","text":"","emotion":""}],"choices":[{"id":"","text":"","targetNodeId":"","condition":{"var":"","op":">=","value":0},"effects":[{"var":"","op":"add","value":1}]}]}],"endings":[{"id":"","nodeId":"","title":"","type":"good|bad|neutral|secret","description":""}]}`;
import { StoryGraphContentToolSchema } from "./tool-schemas.js";
import { validateStoryGraph } from "./validation.js";
const SYSTEM_PROMPT_ZH = `你是互动影游编剧。根据用户的故事前提,生成一个小而完整的可玩分支图。
JSON
${GRAPH_JSON_SHAPE}
1 type=start 2 branch 2 ending endingcondition/effects JSON `;
1 type=start 2 branch 2 ending ending submit_story_graph `;
const SYSTEM_PROMPT_EN = `You are an interactive film scriptwriter. From the user's story premise, generate a small but complete playable branching graph.
Output strictly JSON, with this structure:
${GRAPH_JSON_SHAPE}
Requirements: exactly 1 node with type=start; at least 2 branch nodes; at least 2 clearly differentiated endings; every path must reach some ending; condition/effects may be omitted; output nothing besides the JSON.`;
export function extractJson(text: string): unknown {
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
const body = fenced ? fenced[1] : text;
const start = body.indexOf("{");
const end = body.lastIndexOf("}");
if (start === -1 || end === -1 || end <= start) {
throw new Error("LLM did not return a parseable JSON object / LLM 未返回可解析的 JSON 对象");
}
return JSON.parse(body.slice(start, end + 1));
}
export function buildStoryGraphFromLLMText(text: string, projectId: string): StoryGraph {
const parsed = extractJson(text) as Record<string, unknown>;
const graph = StoryGraphSchema.parse({ ...parsed, projectId });
if (graph.nodes.length === 0) {
throw new Error("Invalid story graph: nodes array must not be empty");
}
return graph;
}
Requirements: exactly 1 node with type=start; at least 2 branch nodes; at least 2 clearly differentiated endings; every path must reach some ending; use variables, conditions, and effects for choices that genuinely change later scenes. Finish by calling submit_story_graph.`;
export interface GenerateStoryGraphInput {
readonly projectId: string;
@@ -58,13 +34,38 @@ export async function generateStoryGraph(
const userPrompt = language === "en"
? `Title: ${input.title}\nPremise: ${input.premise}`
: `标题:${input.title}\n前提:${input.premise}`;
const res = await runWorkerAgent(client, model, appendActivatedSkillGuidance([
const submitted = await runWorkerAgentTool(client, model, appendActivatedSkillGuidance([
{ role: "system", content: systemPrompt },
{ role: "user", content: userPrompt },
], options?.activatedSkills), {
name: "submit_story_graph",
label: language === "en" ? "Submit Story Graph" : "提交故事图谱",
description: language === "en"
? "Submit the complete playable branching graph. The host owns the project id, schema version, and title."
: "提交完整可玩的分支图。项目 id、schema 版本和标题由宿主负责。",
parameters: StoryGraphContentToolSchema,
}, {
temperature: 0.5,
maxTokens: options?.maxTokens ?? 8000,
signal: options?.signal,
});
return buildStoryGraphFromLLMText(res.content, input.projectId);
const graph = StoryGraphSchema.parse({
...submitted,
schemaVersion: 1,
projectId: input.projectId,
title: input.title,
});
const startCount = graph.nodes.filter((node) => node.type === "start").length;
const branchCount = graph.nodes.filter((node) => node.type === "branch").length;
const report = validateStoryGraph(graph);
if (startCount !== 1 || branchCount < 2 || graph.endings.length < 2 || !report.ok) {
const reasons = [
...(startCount !== 1 ? [`expected exactly one start node, received ${startCount}`] : []),
...(branchCount < 2 ? [`expected at least two branch nodes, received ${branchCount}`] : []),
...(graph.endings.length < 2 ? [`expected at least two endings, received ${graph.endings.length}`] : []),
...report.issues.filter((issue) => issue.level === "error").map((issue) => issue.message),
];
throw new Error(`Generated story graph is not playable: ${reasons.join("; ")}`);
}
return graph;
}
@@ -39,7 +39,7 @@ export async function generateNodeImage(params: {
if (!prompt) {
throw new Error(`node ${params.node.id} has no imageSlot.prompt or sceneDesc to generate an image from`);
}
const size = params.size ?? process.env.INKOS_FILM_IMAGE_SIZE ?? "1024x1536";
const size = params.size ?? process.env.INKOS_FILM_IMAGE_SIZE ?? "1536x1024";
const { buffer, extension } = await params.deps.generateImage(prompt, size);
const assetRef = nodeImageRelPath(params.projectId, params.node.id, extension);
const abs = join(params.projectRoot, assetRef);
@@ -0,0 +1,143 @@
import { Type, type Static } from "@sinclair/typebox";
const VarValueToolSchema = Type.Union([Type.Number(), Type.String(), Type.Boolean()]);
const ConditionToolSchema = Type.Object({
var: Type.String({ minLength: 1 }),
op: Type.Union([
Type.Literal(">="),
Type.Literal("<="),
Type.Literal(">"),
Type.Literal("<"),
Type.Literal("=="),
Type.Literal("!="),
]),
value: VarValueToolSchema,
}, { additionalProperties: false });
const EffectToolSchema = Type.Object({
var: Type.String({ minLength: 1 }),
op: Type.Union([Type.Literal("set"), Type.Literal("add"), Type.Literal("sub")]),
value: VarValueToolSchema,
}, { additionalProperties: false });
const ChoiceToolSchema = Type.Object({
id: Type.String({ minLength: 1 }),
text: Type.String(),
targetNodeId: Type.String({ minLength: 1 }),
condition: Type.Optional(ConditionToolSchema),
effects: Type.Optional(Type.Array(EffectToolSchema)),
weight: Type.Optional(Type.Union([
Type.Literal("light"),
Type.Literal("heavy"),
Type.Literal("critical"),
])),
}, { additionalProperties: false });
const DialogueLineToolSchema = Type.Object({
speaker: Type.String(),
text: Type.String(),
emotion: Type.Optional(Type.String()),
}, { additionalProperties: false });
const ImageSlotToolSchema = Type.Object({
prompt: Type.Optional(Type.String()),
assetRef: Type.Optional(Type.String()),
}, { additionalProperties: false });
const NodeTypeToolSchema = Type.Union([
Type.Literal("start"),
Type.Literal("normal"),
Type.Literal("branch"),
Type.Literal("merge"),
Type.Literal("ending"),
Type.Literal("explore"),
]);
const StoryNodeFields = {
title: Type.Optional(Type.String()),
type: NodeTypeToolSchema,
sceneDesc: Type.Optional(Type.String()),
dialogue: Type.Optional(Type.Array(DialogueLineToolSchema)),
choices: Type.Optional(Type.Array(ChoiceToolSchema)),
imageSlot: Type.Optional(ImageSlotToolSchema),
act: Type.Optional(Type.String()),
position: Type.Optional(Type.Object({
x: Type.Number(),
y: Type.Number(),
}, { additionalProperties: false })),
};
export const StoryNodeContentToolSchema = Type.Object(StoryNodeFields, {
additionalProperties: false,
});
export const StoryNodeToolSchema = Type.Object({
id: Type.String({ minLength: 1 }),
...StoryNodeFields,
}, { additionalProperties: false });
const WorldAnchorToolSchema = Type.Object({
storyCore: Type.Optional(Type.String()),
theme: Type.Optional(Type.String()),
genre: Type.Optional(Type.String()),
worldRules: Type.Optional(Type.String()),
durationMinutes: Type.Optional(Type.Number({ minimum: 0 })),
}, { additionalProperties: false });
const CharacterToolSchema = Type.Object({
id: Type.String({ minLength: 1 }),
name: Type.String(),
role: Type.Optional(Type.Union([
Type.Literal("protagonist"),
Type.Literal("antagonist"),
Type.Literal("support"),
Type.Literal("other"),
])),
motivation: Type.Optional(Type.String()),
voiceProfile: Type.Optional(Type.Object({
speakingRhythm: Type.Optional(Type.String()),
vocabulary: Type.Optional(Type.String()),
sampleLines: Type.Optional(Type.Array(Type.String())),
}, { additionalProperties: false })),
}, { additionalProperties: false });
const VariableToolSchema = Type.Object({
name: Type.String({ minLength: 1 }),
type: Type.Union([
Type.Literal("flag"),
Type.Literal("counter"),
Type.Literal("relationship"),
Type.Literal("item"),
]),
default: VarValueToolSchema,
desc: Type.Optional(Type.String()),
}, { additionalProperties: false });
const EndingToolSchema = Type.Object({
id: Type.String({ minLength: 1 }),
nodeId: Type.String({ minLength: 1 }),
title: Type.String(),
type: Type.Union([
Type.Literal("good"),
Type.Literal("bad"),
Type.Literal("neutral"),
Type.Literal("secret"),
]),
description: Type.Optional(Type.String()),
}, { additionalProperties: false });
export const StoryGraphContentToolSchema = Type.Object({
worldAnchor: Type.Optional(WorldAnchorToolSchema),
characters: Type.Optional(Type.Array(CharacterToolSchema)),
variables: Type.Optional(Type.Array(VariableToolSchema)),
nodes: Type.Array(StoryNodeToolSchema, { minItems: 5 }),
endings: Type.Array(EndingToolSchema, { minItems: 2 }),
}, { additionalProperties: false });
export const StoryStructureToolSchema = Type.Object({
nodes: Type.Array(StoryNodeToolSchema, { minItems: 1 }),
}, { additionalProperties: false });
export type StoryNodeContentSubmission = Static<typeof StoryNodeContentToolSchema>;
export type StoryStructureSubmission = Static<typeof StoryStructureToolSchema>;
+50 -5
View File
@@ -738,10 +738,17 @@ export function isTransientLLMHttpError(error: unknown): boolean {
return statusHit || phraseHit;
}
function isIncompleteLLMResponseError(error: unknown): boolean {
const text = collectErrorText(error).toLowerCase();
return text.includes("llm returned reasoning without a final answer")
|| text.includes("llm returned empty response");
}
function isRetryableLLMError(error: unknown): boolean {
// PartialResponseError = 流在生成中途被掐断(网关切长连接等)。重试会完整
// 重新生成一次,比把半截内容当成功交付(截断的章节/设定文件)要正确。
return error instanceof PartialResponseError
|| isIncompleteLLMResponseError(error)
|| isTransientLLMTransportError(error)
|| isTransientLLMHttpError(error);
}
@@ -978,7 +985,12 @@ function extractOpenAITextPart(value: any): string {
function extractChatContent(json: any): string {
const message = json?.choices?.[0]?.message;
return extractOpenAITextPart(message?.content) || extractOpenAITextPart(message?.reasoning_content);
return extractOpenAITextPart(message?.content);
}
function extractChatReasoningContent(json: any): string {
return extractOpenAITextPart(json?.choices?.[0]?.message?.reasoning_content)
|| extractOpenAITextPart(json?.choices?.[0]?.message?.reasoning_details);
}
function extractChatDeltaContent(json: any): string {
@@ -1204,6 +1216,7 @@ async function chatCompletionViaCustomOpenAICompatible(
let content = "";
let usage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
let sawResponseTerminal = false;
let sawResponseIncomplete = false;
const monitor = createStreamMonitor(onStreamProgress);
try {
@@ -1224,6 +1237,7 @@ async function chatCompletionViaCustomOpenAICompatible(
}
if (json.type === "response.completed" || json.type === "response.incomplete") {
sawResponseTerminal = true;
if (json.type === "response.incomplete") sawResponseIncomplete = true;
usage = {
promptTokens: json.response?.usage?.input_tokens ?? 0,
completionTokens: json.response?.usage?.output_tokens ?? 0,
@@ -1246,6 +1260,9 @@ async function chatCompletionViaCustomOpenAICompatible(
// Responses 协议的正常结束必须有 response.completed/incomplete 终止事件
throw new PartialResponseError(content, new Error("stream closed without response.completed"));
}
if (sawResponseIncomplete) {
throw new PartialResponseError(content, new Error("response ended before the final answer was complete"));
}
return { content, usage };
}
@@ -1297,7 +1314,17 @@ async function chatCompletionViaCustomOpenAICompatible(
// MiniMax M2.x 等模型可能把思考内容以 <think>...</think> 内联在 content 开头,
// 剥掉起始处的完整 think 块,防止思考内容混进章节/对话正文(issue #329)。
const content = stripLeadingThinkBlock(extractChatContent(json));
const finishReason = json?.choices?.[0]?.finish_reason;
if (finishReason === "length" || finishReason === "max_tokens") {
throw new PartialResponseError(
content || extractChatReasoningContent(json),
new Error(`model reached the output limit (${finishReason})`),
);
}
if (!content) {
if (extractChatReasoningContent(json)) {
throw wrapLLMError(new Error("LLM returned reasoning without a final answer"), errorCtx);
}
throw wrapLLMError(new Error("LLM returned empty response"), errorCtx);
}
return {
@@ -1320,6 +1347,7 @@ async function chatCompletionViaCustomOpenAICompatible(
// OpenAI 协议的正常结束必须出现 [DONE] 哨兵或带 finish_reason 的 chunk。
// 网关掐断长连接时流会"干净地"关闭但没有任何终止信号——那是截断,不是完成。
let sawTerminal = false;
let terminalFinishReason: string | undefined;
const monitor = createStreamMonitor(onStreamProgress);
// 内联 <think>...</think> 的模型(如 MiniMax M2.x):剥掉响应起始处的完整
// think 块,思考内容既不并入正文也不通过 onTextDelta 发给 UIissue #329)。
@@ -1342,6 +1370,7 @@ async function chatCompletionViaCustomOpenAICompatible(
const json = JSON.parse(event.data);
if (json?.choices?.[0]?.finish_reason) {
sawTerminal = true;
terminalFinishReason = String(json.choices[0].finish_reason);
}
const delta = extractChatDeltaContent(json);
if (delta) {
@@ -1373,14 +1402,22 @@ async function chatCompletionViaCustomOpenAICompatible(
// 流结束仍缓冲在剥离器里的文本(未闭合的 think 块等)原样并回,避免数据丢失。
content += thinkStripper.flush();
const finalContent = content || reasoningContent;
if (!finalContent) {
if (terminalFinishReason === "length" || terminalFinishReason === "max_tokens") {
throw new PartialResponseError(
content || reasoningContent,
new Error(`model reached the output limit (${terminalFinishReason})`),
);
}
if (!content) {
if (reasoningContent) {
throw wrapLLMError(new Error("LLM returned reasoning without a final answer"), errorCtx);
}
throw wrapLLMError(new Error("LLM returned empty response from stream"), errorCtx);
}
if (!sawTerminal) {
throw new PartialResponseError(finalContent, new Error("stream closed without [DONE]/finish_reason"));
throw new PartialResponseError(content, new Error("stream closed without [DONE]/finish_reason"));
}
return { content: finalContent, usage };
return { content, usage };
}
// === Simple Chat (used by all agents via BaseAgent.chat()) ===
@@ -1564,6 +1601,9 @@ async function chatCompletionViaPiAi(
.filter((block): block is { type: "text"; text: string } => block.type === "text")
.map((block) => block.text)
.join("");
if (response.stopReason === "length") {
throw new PartialResponseError(content, new Error("model reached the output limit (length)"));
}
if (!content) {
const diag = `usage=${response.usage.input}+${response.usage.output}`;
console.warn(`[inkos] LLM 非流式响应无文本内容 (${diag})`);
@@ -1585,6 +1625,7 @@ async function chatCompletionViaPiAi(
let inputTokens = 0;
let outputTokens = 0;
let sawDone = false;
let stoppedAtOutputLimit = false;
try {
const iterator = eventStream[Symbol.asyncIterator]();
@@ -1606,6 +1647,7 @@ async function chatCompletionViaPiAi(
outputTokens = msg.usage.output;
if (event.type === "done") {
sawDone = true;
stoppedAtOutputLimit = msg.stopReason === "length";
}
if (event.type === "error" && msg.errorMessage) {
const partial = chunks.join("");
@@ -1630,6 +1672,9 @@ async function chatCompletionViaPiAi(
}
const content = chunks.join("");
if (stoppedAtOutputLimit) {
throw new PartialResponseError(content, new Error("model reached the output limit (length)"));
}
if (!content) {
const diag = `usage=${inputTokens}+${outputTokens}`;
console.warn(`[inkos] LLM 流式响应无文本内容 (${diag})`);
@@ -18,6 +18,8 @@ export interface SettlementRetryParams {
readonly book: BookConfig;
readonly bookDir: string;
readonly chapterNumber: number;
readonly baselineChapter?: number;
readonly allowNewHooks?: boolean;
readonly title: string;
readonly content: string;
readonly reducedControlInput?: {
@@ -59,6 +61,8 @@ export async function retrySettlementAfterValidationFailure(
title: params.title,
content: params.content,
allowReapply: true,
baselineChapter: params.baselineChapter,
allowNewHooks: params.allowNewHooks,
chapterIntent: params.reducedControlInput?.chapterIntent,
contextPackage: params.reducedControlInput?.contextPackage,
ruleStack: params.reducedControlInput?.ruleStack,
+225 -72
View File
@@ -8,7 +8,10 @@ import type { ChapterMeta } from "../models/chapter.js";
import type { NotifyChannel, LLMConfig, AgentLLMOverride, InputGovernanceMode } from "../models/project.js";
import type { GenreProfile } from "../models/genre-profile.js";
import { ArchitectAgent, type ArchitectOutput } from "../agents/architect.js";
import { FoundationReviewerAgent } from "../agents/foundation-reviewer.js";
import {
FoundationReviewerAgent,
FoundationReviewParseError,
} from "../agents/foundation-reviewer.js";
import { PlannerAgent, type PlanChapterOutput } from "../agents/planner.js";
import { ComposerAgent, composeGovernedChapter, contextBudgetFromClient, type ComposeChapterOutput } from "../agents/composer.js";
import { WriterAgent, type WriteChapterInput, type WriteChapterOutput } from "../agents/writer.js";
@@ -57,6 +60,7 @@ import { validateChapterTruthPersistence } from "./chapter-truth-validation.js";
import { loadPersistedPlan, relativeToBookDir, savePersistedPlan } from "./persisted-governed-plan.js";
import { selectBookReferenceContext } from "../references/reference-context.js";
import type { ActivatedSkillGuidance } from "../agent/skill-tool.js";
import { commitAtomicFileSet } from "../utils/atomic-file-set.js";
const SEQUENCE_LEVEL_CATEGORIES = new Set([
"Pacing Monotony", "节奏单调",
@@ -377,6 +381,13 @@ export interface ReviseResult {
readonly fixedIssues: ReadonlyArray<string>;
readonly applied: boolean;
readonly status: "unchanged" | "ready-for-review" | "audit-failed";
readonly auditPassed?: boolean;
readonly auditIssues?: ReadonlyArray<{
readonly severity: AuditIssue["severity"];
readonly category: string;
readonly description: string;
readonly suggestion?: string;
}>;
readonly skippedReason?: string;
readonly revisionDiagnostics?: {
readonly standard: string;
@@ -588,14 +599,24 @@ export class PipelineRunner {
en: `reviewing foundation (round ${attempt + 1})`,
});
const review = await params.reviewer.review({
foundation,
mode: params.mode,
sourceCanon: params.sourceCanon,
styleGuide: params.styleGuide,
language: params.language,
targetChapters: params.targetChapters,
});
let review;
try {
review = await params.reviewer.review({
foundation,
mode: params.mode,
sourceCanon: params.sourceCanon,
styleGuide: params.styleGuide,
language: params.language,
targetChapters: params.targetChapters,
});
} catch (error) {
if (!(error instanceof FoundationReviewParseError)) throw error;
this.logWarn(params.stageLanguage, {
zh: `基础设定审核输出无法解析,已保留当前版本且不会自动重生成:${error.message}`,
en: `Foundation review output could not be parsed; keeping the current version without automatic regeneration: ${error.message}`,
});
return foundation;
}
this.config.logger?.info(
`Foundation review: ${review.totalScore}/100 ${review.passed ? "PASSED" : "REJECTED"}`,
@@ -617,14 +638,24 @@ export class PipelineRunner {
}
// Final review
const finalReview = await params.reviewer.review({
foundation,
mode: params.mode,
sourceCanon: params.sourceCanon,
styleGuide: params.styleGuide,
language: params.language,
targetChapters: params.targetChapters,
});
let finalReview;
try {
finalReview = await params.reviewer.review({
foundation,
mode: params.mode,
sourceCanon: params.sourceCanon,
styleGuide: params.styleGuide,
language: params.language,
targetChapters: params.targetChapters,
});
} catch (error) {
if (!(error instanceof FoundationReviewParseError)) throw error;
this.logWarn(params.stageLanguage, {
zh: `基础设定最终审核输出无法解析,已保留当前版本:${error.message}`,
en: `Final foundation review output could not be parsed; keeping the current version: ${error.message}`,
});
return foundation;
}
this.config.logger?.info(
`Foundation final review: ${finalReview.totalScore}/100 ${finalReview.passed ? "PASSED" : "ACCEPTED (max retries)"}`,
);
@@ -1465,6 +1496,16 @@ export class PipelineRunner {
chapterLengthTarget,
lengthLanguage,
);
const baselineChapter = targetChapter - 1;
const baselineStoryDir = join(bookDir, "story", "snapshots", String(baselineChapter));
const [baselineState, baselineHooks] = await Promise.all([
readFile(join(baselineStoryDir, "current_state.md"), "utf-8"),
readFile(join(baselineStoryDir, "pending_hooks.md"), "utf-8"),
]).catch((error) => {
throw new Error(
`Cannot revise chapter ${targetChapter} safely: baseline snapshot ${baselineChapter} is unavailable (${String(error)})`,
);
});
const reviser = new ReviserAgent(this.agentCtxFor("reviser", bookId));
this.logStage(stageLanguage, {
@@ -1486,8 +1527,9 @@ export class PipelineRunner {
contextPackage: reviseControlInput.composed.contextPackage,
ruleStack: reviseControlInput.composed.ruleStack,
lengthSpec,
baselineChapter,
}
: { lengthSpec },
: { lengthSpec, baselineChapter },
);
if (reviseOutput.revisedContent.length === 0) {
@@ -1499,6 +1541,80 @@ export class PipelineRunner {
chapterContent: reviseOutput.revisedContent,
lengthSpec,
});
const writer = new WriterAgent(this.agentCtxFor("writer", bookId));
const stateValidator = new StateValidatorAgent(this.agentCtxFor("stateValidator", bookId));
let settledRevision = await writer.settleChapterState({
book,
bookDir,
chapterNumber: targetChapter,
baselineChapter,
title: chapterMeta.title,
content: normalizedRevision.content,
chapterIntent: reviseControlInput?.plan.intentMarkdown,
contextPackage: reviseControlInput?.composed.contextPackage,
ruleStack: reviseControlInput?.composed.ruleStack,
});
let stateValidation = await stateValidator.validate(
normalizedRevision.content,
targetChapter,
baselineState,
settledRevision.updatedState,
baselineHooks,
settledRevision.updatedHooks,
language,
);
if (!stateValidation.passed || stateValidation.repairRequired) {
const recovery = await retrySettlementAfterValidationFailure({
writer,
validator: stateValidator,
book,
bookDir,
chapterNumber: targetChapter,
baselineChapter,
title: chapterMeta.title,
content: normalizedRevision.content,
reducedControlInput: reviseControlInput
? {
chapterIntent: reviseControlInput.plan.intentMarkdown,
contextPackage: reviseControlInput.composed.contextPackage,
ruleStack: reviseControlInput.composed.ruleStack,
}
: undefined,
oldState: baselineState,
oldHooks: baselineHooks,
originalValidation: stateValidation,
language,
logger: this.config.logger,
});
if (recovery.kind === "degraded") {
return {
chapterNumber: targetChapter,
wordCount: countChapterLength(content, countingMode),
fixedIssues: [],
applied: false,
status: "unchanged",
auditPassed: false,
auditIssues: recovery.issues,
skippedReason: `Revision kept the original chapter because state settlement did not validate after retry.`,
revisionDiagnostics: {
standard: "Revision text and derived story state must both validate before any file is replaced.",
before: {
blockingCount: preRevision.blockingCount,
criticalCount: preRevision.criticalCount,
aiTellCount: preRevision.aiTellCount,
},
after: {
blockingCount: preRevision.blockingCount,
criticalCount: preRevision.criticalCount,
aiTellCount: preRevision.aiTellCount,
},
remainingIssues: recovery.issues,
},
};
}
settledRevision = recovery.output;
stateValidation = recovery.validation;
}
const postRevision = await this.evaluateMergedAudit({
auditor,
book,
@@ -1514,17 +1630,17 @@ export class PipelineRunner {
contextPackage: reviseControlInput.composed.contextPackage,
ruleStack: reviseControlInput.composed.ruleStack,
truthFileOverrides: {
currentState: reviseOutput.updatedState !== "(状态卡未更新)" ? reviseOutput.updatedState : undefined,
ledger: reviseOutput.updatedLedger !== "(账本未更新)" ? reviseOutput.updatedLedger : undefined,
hooks: reviseOutput.updatedHooks !== "(伏笔池未更新)" ? reviseOutput.updatedHooks : undefined,
currentState: settledRevision.updatedState,
ledger: settledRevision.updatedLedger || undefined,
hooks: settledRevision.updatedHooks,
},
}
: {
temperature: 0,
truthFileOverrides: {
currentState: reviseOutput.updatedState !== "(状态卡未更新)" ? reviseOutput.updatedState : undefined,
ledger: reviseOutput.updatedLedger !== "(账本未更新)" ? reviseOutput.updatedLedger : undefined,
hooks: reviseOutput.updatedHooks !== "(伏笔池未更新)" ? reviseOutput.updatedHooks : undefined,
currentState: settledRevision.updatedState,
ledger: settledRevision.updatedLedger || undefined,
hooks: settledRevision.updatedHooks,
},
},
});
@@ -1560,17 +1676,31 @@ export class PipelineRunner {
: revisionGate === "lenient"
? didNotWorsen
: didNotWorsen && (improvedBlocking || improvedAITells);
const remainingIssues = effectivePostRevision.revisionBlockingIssues
.filter((issue) => issue.severity === "warning" || issue.severity === "critical")
.slice(0, 6)
.map((issue) => ({
severity: issue.severity,
category: issue.category,
description: issue.description,
...(issue.suggestion ? { suggestion: issue.suggestion } : {}),
}));
const revisionDiagnostics = {
standard: REVISION_GATE_STANDARDS[revisionGate],
before: {
blockingCount: preRevision.blockingCount,
criticalCount: preRevision.criticalCount,
aiTellCount: preRevision.aiTellCount,
},
after: {
blockingCount: effectivePostRevision.blockingCount,
criticalCount: effectivePostRevision.criticalCount,
aiTellCount: effectivePostRevision.aiTellCount,
},
remainingIssues,
};
if (!shouldApplyRevision) {
const remainingIssues = effectivePostRevision.revisionBlockingIssues
.filter((issue) => issue.severity === "warning" || issue.severity === "critical")
.slice(0, 6)
.map((issue) => ({
severity: issue.severity,
category: issue.category,
description: issue.description,
...(issue.suggestion ? { suggestion: issue.suggestion } : {}),
}));
return {
chapterNumber: targetChapter,
wordCount: revisionBaseCount,
@@ -1578,20 +1708,9 @@ export class PipelineRunner {
applied: false,
status: "unchanged",
skippedReason: `Manual revision kept original chapter: before blocking=${preRevision.blockingCount}, critical=${preRevision.criticalCount}, aiTell=${preRevision.aiTellCount}; after blocking=${effectivePostRevision.blockingCount}, critical=${effectivePostRevision.criticalCount}, aiTell=${effectivePostRevision.aiTellCount}.`,
revisionDiagnostics: {
standard: REVISION_GATE_STANDARDS[revisionGate],
before: {
blockingCount: preRevision.blockingCount,
criticalCount: preRevision.criticalCount,
aiTellCount: preRevision.aiTellCount,
},
after: {
blockingCount: effectivePostRevision.blockingCount,
criticalCount: effectivePostRevision.criticalCount,
aiTellCount: effectivePostRevision.aiTellCount,
},
remainingIssues,
},
auditPassed: effectivePostRevision.auditResult.passed,
auditIssues: remainingIssues,
revisionDiagnostics,
};
}
this.logLengthWarnings(lengthWarnings);
@@ -1613,26 +1732,19 @@ export class PipelineRunner {
const reviseHeading = reviseLang === "en"
? `# Chapter ${targetChapter}: ${chapterMeta.title}`
: `# 第${targetChapter}${chapterMeta.title}`;
await writeFile(
join(chaptersDir, existingFile),
`${reviseHeading}\n\n${normalizedRevision.content}`,
"utf-8",
);
// Only the latest chapter owns current truth. Reworking an older chapter
// invalidates its descendants, but must not rewind the live story state.
if (isLatestChapter) {
const storyDir = join(bookDir, "story");
if (reviseOutput.updatedState !== "(状态卡未更新)") {
await writeFile(join(storyDir, "current_state.md"), reviseOutput.updatedState, "utf-8");
}
if (gp.numericalSystem && reviseOutput.updatedLedger && reviseOutput.updatedLedger !== "(账本未更新)") {
await writeFile(join(storyDir, "particle_ledger.md"), reviseOutput.updatedLedger, "utf-8");
}
if (reviseOutput.updatedHooks !== "(伏笔池未更新)") {
await writeFile(join(storyDir, "pending_hooks.md"), reviseOutput.updatedHooks, "utf-8");
}
await this.syncLegacyStructuredStateFromMarkdown(bookDir, targetChapter);
await writer.saveChapter(bookDir, settledRevision, gp.numericalSystem, reviseLang);
} else {
await commitAtomicFileSet({
rootDir: bookDir,
writes: [{
relativePath: join("chapters", existingFile),
content: `${reviseHeading}\n\n${normalizedRevision.content}`,
}],
});
}
// Update index
@@ -1700,6 +1812,9 @@ export class PipelineRunner {
fixedIssues: reviseOutput.fixedIssues,
applied: true,
status: effectivePostRevision.auditResult.passed ? "ready-for-review" : "audit-failed",
auditPassed: effectivePostRevision.auditResult.passed,
auditIssues: remainingIssues,
revisionDiagnostics,
lengthWarnings,
lengthTelemetry,
};
@@ -1833,6 +1948,24 @@ export class PipelineRunner {
}
}
async resyncChapterStateAndAudit(
bookId: string,
chapterNumber?: number,
options: { readonly allowNewHooks?: boolean } = {},
): Promise<{
readonly chapter: ChapterPipelineResult;
readonly audit: AuditResult & { readonly chapterNumber: number };
}> {
const releaseLock = await this.state.acquireBookLock(bookId);
try {
const chapter = await this._resyncChapterArtifactsLocked(bookId, chapterNumber, options);
const audit = await this.auditDraft(bookId, chapter.chapterNumber);
return { chapter, audit };
} finally {
await releaseLock();
}
}
private async _writeNextChapterLocked(
bookId: string,
wordCount?: number,
@@ -2267,17 +2400,23 @@ export class PipelineRunner {
const { profile: gp } = await this.loadGenreProfile(book.genre);
const pipelineLang = book.language ?? gp.language;
const content = await this.readChapterContent(bookDir, targetChapter);
const storyDir = join(bookDir, "story");
const baselineChapter = targetChapter - 1;
const baselineStoryDir = join(bookDir, "story", "snapshots", String(baselineChapter));
const [oldState, oldHooks] = await Promise.all([
readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => ""),
readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""),
]);
readFile(join(baselineStoryDir, "current_state.md"), "utf-8"),
readFile(join(baselineStoryDir, "pending_hooks.md"), "utf-8"),
]).catch((error) => {
throw new Error(
`Cannot repair chapter ${targetChapter} safely: baseline snapshot ${baselineChapter} is unavailable (${String(error)})`,
);
});
const writer = new WriterAgent(this.agentCtxFor("writer", bookId));
let repairedOutput = await writer.settleChapterState({
book,
bookDir,
chapterNumber: targetChapter,
baselineChapter,
title: targetMeta.title,
content,
allowReapply: true,
@@ -2300,6 +2439,7 @@ export class PipelineRunner {
book,
bookDir,
chapterNumber: targetChapter,
baselineChapter,
title: targetMeta.title,
content,
oldState,
@@ -2360,7 +2500,11 @@ export class PipelineRunner {
};
}
private async _resyncChapterArtifactsLocked(bookId: string, chapterNumber?: number): Promise<ChapterPipelineResult> {
private async _resyncChapterArtifactsLocked(
bookId: string,
chapterNumber?: number,
options: { readonly allowNewHooks?: boolean } = {},
): Promise<ChapterPipelineResult> {
const book = await this.state.loadBookConfig(bookId);
const bookDir = this.state.bookDir(bookId);
const stageLanguage = await this.resolveBookLanguage(book);
@@ -2385,11 +2529,16 @@ export class PipelineRunner {
const { profile: gp } = await this.loadGenreProfile(book.genre);
const pipelineLang = book.language ?? gp.language;
const content = await this.readChapterContent(bookDir, targetChapter);
const storyDir = join(bookDir, "story");
const baselineChapter = targetChapter - 1;
const baselineStoryDir = join(bookDir, "story", "snapshots", String(baselineChapter));
const [oldState, oldHooks] = await Promise.all([
readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => ""),
readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""),
]);
readFile(join(baselineStoryDir, "current_state.md"), "utf-8"),
readFile(join(baselineStoryDir, "pending_hooks.md"), "utf-8"),
]).catch((error) => {
throw new Error(
`Cannot sync chapter ${targetChapter} safely: baseline snapshot ${baselineChapter} is unavailable (${String(error)})`,
);
});
const reducedControlInput = (this.config.inputGovernanceMode ?? "v2") === "legacy"
? undefined
@@ -2406,6 +2555,8 @@ export class PipelineRunner {
book,
bookDir,
chapterNumber: targetChapter,
baselineChapter,
allowNewHooks: options.allowNewHooks,
title: targetMeta.title,
content,
chapterIntent: reducedControlInput?.plan.intentMarkdown,
@@ -2431,6 +2582,8 @@ export class PipelineRunner {
book,
bookDir,
chapterNumber: targetChapter,
baselineChapter,
allowNewHooks: options.allowNewHooks,
title: targetMeta.title,
content,
reducedControlInput: reducedControlInput
@@ -320,9 +320,33 @@ export async function runStoryboardCreation(
options.onProgress?.("Writing storyboard and image prompts...");
const agent = new StoryboardCreationAgent(options.runtime);
const storyboard = await agent.writeStoryboard(input);
const segments = splitStoryboardSource(input.sourceText, input.maxShots);
const storyboardParts: string[] = [];
for (const [index, segment] of segments.entries()) {
if (segments.length > 1) {
options.onProgress?.(`Writing storyboard segment ${index + 1}/${segments.length}: ${segment.label}...`);
}
storyboardParts.push(await agent.writeStoryboard({
...input,
sourceText: segment.sourceText,
...(segments.length > 1 ? {
segment: {
label: segment.label,
index,
count: segments.length,
estimatedShots: Math.ceil((input.maxShots ?? 24) / segments.length),
},
} : {}),
}));
}
const storyboard = storyboardParts.join("\n\n");
await writeProjectText(options.projectRoot, join(baseDir, "storyboard.md"), storyboard);
const imagePrompts = extractStoryboardImagePrompts(storyboard);
// Extract each segment before concatenation because a Markdown section
// extractor correctly returns only the first matching heading.
const imagePrompts = storyboardParts
.map((part) => extractStoryboardImagePrompts(part))
.filter(Boolean)
.join("\n\n");
await writeProjectText(options.projectRoot, join(baseDir, "image-prompts.md"), imagePrompts);
await ensureProjectDir(options.projectRoot, join(baseDir, "assets", "source"));
await ensureProjectDir(options.projectRoot, join(baseDir, "assets", "generated"));
@@ -358,6 +382,74 @@ export async function runStoryboardCreation(
};
}
interface StoryboardSourceSegment {
readonly label: string;
readonly sourceText: string;
}
/**
* Large storyboards are generated one explicit document section at a time so
* no model call has to emit the entire deliverable. This parses Markdown
* structure only; it does not infer story meaning or discard source text.
*/
function splitStoryboardSource(
sourceText: string | undefined,
maxShots: number | undefined,
): StoryboardSourceSegment[] {
const source = sourceText?.trim();
if (!source || (maxShots ?? 24) * 700 <= 24_000) {
return [{ label: "full storyboard", sourceText: source ?? "" }];
}
const lines = source.split(/\r?\n/);
const headings: Array<{ readonly line: number; readonly label: string }> = [];
for (const [line, raw] of lines.entries()) {
const heading = /^#{1,6}\s+(.+?)\s*$/u.exec(raw.trim());
if (!heading) continue;
const label = heading[1]!.trim();
if (/^\s*[\d]+\s*(?:\s||$)/u.test(label)
|| /^episode\s+\d+(?:\s|[:\-]|$)/iu.test(label)) {
headings.push({ line, label });
}
}
if (headings.length < 2) {
return [{ label: "full storyboard", sourceText: source }];
}
const episodeSegments = headings.map((heading, index) => {
const start = index === 0 ? 0 : heading.line;
const end = headings[index + 1]?.line ?? lines.length;
return {
label: heading.label,
sourceText: lines.slice(start, end).join("\n").trim(),
};
});
return episodeSegments.flatMap(splitStoryboardEpisodeScenes);
}
function splitStoryboardEpisodeScenes(episode: StoryboardSourceSegment): StoryboardSourceSegment[] {
const lines = episode.sourceText.split(/\r?\n/);
const boundaries: Array<{ readonly line: number; readonly label: string }> = [];
for (const [line, raw] of lines.entries()) {
const text = raw.trim();
const bold = /^\*\*(.+?)\*\*(?:\s.*)?$/u.exec(text);
const label = bold?.[1]?.trim();
if (!label) continue;
if (/^(?:\s*\d+|)(?:\s|[:/]|$)/u.test(label)
|| /^(?:scene\s+\d+|episode[- ]end hook)(?:\s|[:/\-]|$)/iu.test(label)) {
boundaries.push({ line, label });
}
}
if (boundaries.length < 2) return [episode];
return boundaries.map((boundary, index) => ({
label: `${episode.label} / ${boundary.label}`,
sourceText: lines
.slice(index === 0 ? 0 : boundary.line, boundaries[index + 1]?.line ?? lines.length)
.join("\n")
.trim(),
}));
}
async function createInteractiveFilmStoryGraph(
runtime: AgentContext,
args: {
@@ -371,20 +463,18 @@ async function createInteractiveFilmStoryGraph(
readonly onProgress?: (message: string) => void;
},
): Promise<StoryGraph> {
try {
return await generateStoryGraph(runtime.client, runtime.model, {
projectId: args.projectId,
title: args.title,
premise: buildInteractiveFilmGraphPremise(args.input, args.storyTree, args.flags, args.script, args.imagePrompts),
}, {
language: args.input.language,
activatedSkills: runtime.activatedSkills,
signal: runtime.signal,
});
} catch (error) {
args.onProgress?.(`Story graph JSON generation failed; writing a minimal playable graph. ${formatError(error)}`);
return buildFallbackStoryGraph(args.projectId, args.title, args.input, args.imagePrompts);
}
args.onProgress?.(args.input.language === "en"
? "Building the playable story graph through the structured authoring harness..."
: "正在通过结构化创作内核生成可玩故事图谱……");
return generateStoryGraph(runtime.client, runtime.model, {
projectId: args.projectId,
title: args.title,
premise: buildInteractiveFilmGraphPremise(args.input, args.storyTree, args.flags, args.script, args.imagePrompts),
}, {
language: args.input.language,
activatedSkills: runtime.activatedSkills,
signal: runtime.signal,
});
}
function buildInteractiveFilmGraphPremise(
@@ -422,112 +512,6 @@ function buildInteractiveFilmGraphPremise(
].filter(Boolean).join("\n\n");
}
function buildFallbackStoryGraph(
projectId: string,
title: string,
input: InteractiveFilmCreationInput,
imagePrompts: string,
): StoryGraph {
const en = (input.language ?? "zh") === "en";
const prompts = parseStoryboardPromptLines(imagePrompts);
const actCount = Math.max(2, Math.min(8, (input.episodeCount ?? prompts.length) || 3));
const nodes: StoryGraph["nodes"] = [
{
id: "start",
title: en ? "Opening" : "开场",
type: "start",
sceneDesc: input.requirements || title,
dialogue: [],
choices: [{ id: "start-act-1", text: en ? "Enter Act 1" : "进入第一幕", targetNodeId: "act-1", effects: [] }],
imageSlot: { prompt: prompts[0] ?? input.requirements ?? title },
act: "start",
position: { x: 0, y: 0 },
},
];
for (let index = 1; index <= actCount; index += 1) {
const isLast = index === actCount;
nodes.push({
id: `act-${index}`,
title: en ? `Act ${index}` : `${index}`,
type: isLast ? "branch" : "normal",
sceneDesc: en ? `Act ${index} of the interactive film "${title}".` : `互动影游《${title}》第 ${index} 幕。`,
dialogue: [],
choices: isLast
? [
{ id: "to-ending-a", text: en ? "Complete the main objective" : "完成主线目标", targetNodeId: "ending-a", effects: [{ var: "story_progress", op: "add", value: 1 }] },
{ id: "to-ending-b", text: en ? "Take the other aftermath" : "进入另一条余波", targetNodeId: "ending-b", effects: [{ var: "story_progress", op: "add", value: 1 }] },
]
: [{ id: `act-${index}-next`, text: en ? "Keep going" : "继续推进", targetNodeId: `act-${index + 1}`, effects: [{ var: "story_progress", op: "add", value: 1 }] }],
imageSlot: { prompt: prompts[index - 1] ?? prompts[0] ?? input.requirements ?? title },
act: `act-${index}`,
position: { x: index * 260, y: index % 2 === 0 ? 120 : 0 },
});
}
nodes.push(
{
id: "ending-a",
title: en ? "Ending One" : "结局一",
type: "ending",
sceneDesc: en ? "The main objective is completed and the story converges." : "主线目标被完成,故事进入收束。",
dialogue: [],
choices: [],
act: "ending",
position: { x: (actCount + 1) * 260, y: -80 },
},
{
id: "ending-b",
title: en ? "Ending Two" : "结局二",
type: "ending",
sceneDesc: en
? "The player keeps the other aftermath and the story closes on the forked path."
: "玩家选择保留另一条余波,故事进入分岔收束。",
dialogue: [],
choices: [],
act: "ending",
position: { x: (actCount + 1) * 260, y: 120 },
},
);
return {
schemaVersion: 1,
projectId,
title,
worldAnchor: {
storyCore: input.requirements || title,
theme: "",
genre: "interactive-film",
worldRules: input.referenceMode ?? "",
durationMinutes: 0,
},
characters: [],
variables: [{
name: "story_progress",
type: "counter",
default: 0,
desc: en ? "Story progression" : "剧情推进进度",
}],
nodes,
endings: [
{
id: "ending-a",
nodeId: "ending-a",
title: en ? "Ending One" : "结局一",
type: "neutral",
description: en ? "The main objective is completed." : "主线目标被完成。",
},
{
id: "ending-b",
nodeId: "ending-b",
title: en ? "Ending Two" : "结局二",
type: "secret",
description: en ? "The player keeps the other aftermath." : "玩家选择保留另一条余波。",
},
],
};
}
function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
@@ -51,6 +51,7 @@ export interface ShortFictionRunRuntimes {
export interface ShortFictionRunOptions {
readonly projectRoot: string;
readonly title?: string;
readonly direction: string;
readonly runtimes: ShortFictionRunRuntimes;
readonly reference?: ShortFictionReference;
@@ -114,7 +115,11 @@ export async function runShortFictionProduction(
): Promise<ShortFictionRunResult> {
const root = options.projectRoot;
const outDir = normalizeOutputDir(options.outDir ?? "shorts");
const providedStoryId = options.storyId ? safeSegment(options.storyId) : undefined;
const providedStoryId = options.storyId
? safeSegment(options.storyId)
: options.title?.trim()
? safeSegment(slugify(options.title))
: undefined;
// A stable storyId lets a re-run resume from disk instead of redoing finished
// work — a transient failure in a late stage used to throw the whole short
@@ -179,6 +184,7 @@ async function produceShort(
: undefined;
let outlineMarkdown: string;
let outlineRevisionWarning: string | undefined;
let storyId: string;
let baseDir: string;
if (providedStoryId && resumedOutline?.trim()) {
@@ -213,17 +219,42 @@ async function produceShort(
options.onProgress?.("Revising outline once...");
const outlineReviser = new ShortFictionOutlineReviserAgent(options.runtimes.planner);
const outlineV2 = await outlineReviser.reviseOutline({
direction: options.direction,
outline: outlineV1,
review: outlineReview,
reference: options.reference,
chapterCount,
charsPerChapter,
language,
});
await writeText(root, join(baseDir, "outline", "v002.md"), outlineV2.rawContent);
outlineMarkdown = outlineV2.rawContent;
try {
const outlineV2 = await outlineReviser.reviseOutline({
direction: options.direction,
outline: outlineV1,
review: outlineReview,
reference: options.reference,
chapterCount,
charsPerChapter,
language,
});
await writeText(root, join(baseDir, "outline", "v002.md"), outlineV2.rawContent);
outlineMarkdown = outlineV2.rawContent;
} catch (error) {
outlineRevisionWarning = error instanceof Error ? error.message : String(error);
outlineMarkdown = outlineV1.rawContent;
await writeText(root, join(baseDir, "outline", "v002.md"), outlineMarkdown);
await writeText(root, join(baseDir, "reviews", "outline-v002-warning.md"), language === "en"
? [
"# Outline revision not adopted",
"",
"The complete first outline remains authoritative because the optional revision did not finish cleanly.",
"",
"## Reason",
"",
outlineRevisionWarning,
].join("\n")
: [
"# 第二版大纲未采用",
"",
"可用的第一版大纲继续生效;可选修订没有完整结束,系统没有用残缺输出覆盖它。",
"",
"## 原因",
"",
outlineRevisionWarning,
].join("\n"));
}
}
let finalDraft: ShortFictionBatchDraft;
@@ -349,10 +380,14 @@ async function produceShort(
return { coverError: String(error) };
});
if (revisionWarning) {
const completionWarnings = [
outlineRevisionWarning ? `outline revision skipped: ${outlineRevisionWarning}` : "",
revisionWarning ? `draft revision skipped: ${revisionWarning}` : "",
].filter(Boolean);
if (completionWarnings.length > 0) {
await writeShortRunStatus(root, baseDir, {
status: "complete",
warning: `revision skipped: ${revisionWarning}`,
warning: completionWarnings.join("; "),
}).catch(() => undefined);
}
+219 -90
View File
@@ -1,4 +1,5 @@
import { z } from "zod";
import { Type } from "@mariozechner/pi-ai";
import { BaseAgent, type AgentContext } from "../agents/base.js";
import {
PlayActionIntentSchema,
@@ -27,6 +28,7 @@ export interface PlayWorldMutatorInput {
export interface PlaySceneRenderInput {
readonly input: string;
readonly action: PlayActionIntentInput;
readonly context?: string;
readonly mutationSummary: string;
readonly stateBrief: string;
readonly replayContext?: string;
@@ -55,10 +57,103 @@ const PlaySceneRenderSchema = z.object({
});
export type PlaySceneRender = z.infer<typeof PlaySceneRenderSchema>;
const PlayEntityResultSchema = Type.Object({
id: Type.Optional(Type.String()),
type: Type.Union([
Type.Literal("actor"), Type.Literal("location"), Type.Literal("item"),
Type.Literal("evidence"), Type.Literal("clue"), Type.Literal("claim"),
Type.Literal("proof_chain"), Type.Literal("organization"), Type.Literal("rule"),
Type.Literal("scene"), Type.Literal("event"),
]),
label: Type.String(),
summary: Type.Optional(Type.String()),
status: Type.Optional(Type.String()),
});
const PlayEdgeResultSchema = Type.Object({
id: Type.Optional(Type.String()),
fromId: Type.String(),
type: Type.String(),
toId: Type.String(),
value: Type.Optional(Type.Record(Type.String(), Type.Unknown())),
visibility: Type.Optional(Type.Record(Type.String(), Type.String())),
strength: Type.Optional(Type.Number()),
confidence: Type.Optional(Type.Number()),
});
const PlayStateSlotResultSchema = Type.Object({
id: Type.Optional(Type.String()),
ownerEntityId: Type.Optional(Type.Union([Type.String(), Type.Null()])),
kind: Type.Union([
Type.Literal("resource"), Type.Literal("relation"), Type.Literal("pressure"),
Type.Literal("clue"), Type.Literal("evidence"), Type.Literal("flag"), Type.Literal("timer"),
]),
label: Type.String(),
value: Type.Unknown(),
});
const PlayMutationResultSchema = Type.Object({
summary: Type.Optional(Type.String()),
timeAdvance: Type.Optional(Type.Object({
elapsed: Type.String(),
anchor: Type.Optional(Type.String()),
rationale: Type.Optional(Type.String()),
synchronized: Type.Optional(Type.Array(Type.String())),
})),
entities: Type.Optional(Type.Array(PlayEntityResultSchema)),
edges: Type.Optional(Type.Array(PlayEdgeResultSchema)),
expiredEdges: Type.Optional(Type.Array(Type.Object({
edgeId: Type.String(),
reason: Type.Optional(Type.String()),
}))),
stateSlots: Type.Optional(Type.Array(PlayStateSlotResultSchema)),
evidenceTransitions: Type.Optional(Type.Array(Type.Object({
entityId: Type.String(),
from: Type.Optional(Type.Union([
Type.Literal("unknown"), Type.Literal("hinted"), Type.Literal("seen"),
Type.Literal("collected"), Type.Literal("verified"), Type.Literal("weaponized"),
Type.Literal("exposed"), Type.Literal("exhausted"),
])),
to: Type.Union([
Type.Literal("unknown"), Type.Literal("hinted"), Type.Literal("seen"),
Type.Literal("collected"), Type.Literal("verified"), Type.Literal("weaponized"),
Type.Literal("exposed"), Type.Literal("exhausted"),
]),
reason: Type.Optional(Type.String()),
}))),
blocked: Type.Optional(Type.Boolean()),
blockedReason: Type.Optional(Type.String()),
notes: Type.Optional(Type.Array(Type.String())),
});
const WORLD_MUTATION_TOOL = {
name: "submit_world_mutation",
label: "Submit world mutation",
description: "Submit the complete world-state transition caused by this action. Host-owned event metadata is intentionally omitted.",
parameters: PlayMutationResultSchema,
} as const;
const GRAPH_RECONCILIATION_TOOL = {
name: "submit_graph_reconciliation",
label: "Submit graph reconciliation",
description: "Submit only graph facts present in the rendered scene but missing from the applied mutation. Submit empty arrays when nothing is missing.",
parameters: PlayMutationResultSchema,
} as const;
const PLAY_SCENE_RENDER_TOOL = {
name: "submit_play_scene",
label: "Submit play scene",
description: "Submit the rendered scene and up to four immediate player actions grounded in the applied world state.",
parameters: Type.Object({
sceneText: Type.String({ minLength: 1 }),
suggestedActions: Type.Array(Type.String({ minLength: 1 }), { maxItems: 4 }),
}),
} as const;
// A play turn runs three internal LLM calls (interpret → mutate → render). The
// transport-level retry in the provider does NOT cover HTTP 502/503/429 or
// "temporarily unavailable", so a single flaky upstream response would break the
// whole turn. Retry those here, then let each agent fail open.
// whole turn. Retry those here; each agent then applies its own safe failure policy.
function isRetryableLlmError(err: unknown): boolean {
const msg = (err instanceof Error ? err.message : String(err)).toLowerCase();
return /50[0-9]|429|temporarily unavailable|timeout|timed out|socket|terminated|econn|network|fetch failed|bad gateway|service unavailable|rate limit/.test(msg);
@@ -78,14 +173,6 @@ async function chatWithRetry<T>(call: () => Promise<T>, retries = 2): Promise<T>
throw lastErr;
}
function trySceneParse(content: string): PlaySceneRender | null {
try {
return PlaySceneRenderSchema.parse(parseJson(content));
} catch {
return null;
}
}
export class PlayActionInterpreterAgent extends BaseAgent {
constructor(ctx: AgentContext) {
super(ctx);
@@ -123,37 +210,103 @@ export class PlayWorldMutatorAgent extends BaseAgent {
}
async proposeMutation(input: PlayWorldMutatorInput): Promise<PlayMutation> {
// Never throw: a transient upstream error (after retries) or an unparseable
// mutation degrades to a blocked, no-op turn (with a reason), not a crash.
let raw: unknown = {};
try {
const systemPrompt = await appendPromptPackGuidance(
buildWorldMutatorSystemPrompt(input.language ?? "zh"),
{ promptId: "play.mutator", projectRoot: this.ctx.projectRoot },
);
const response = await chatWithRetry(() => this.chat([
{ role: "system", content: systemPrompt },
{ role: "user", content: buildWorldMutatorUserPrompt(input, input.language ?? "zh") },
], { temperature: 0.25, maxTokens: 4096 }));
raw = parseJson(response.content);
} catch { /* transient/malformed → degrade below */ }
const parsed = PlayMutationSchema.safeParse(raw);
const mutation = parsed.success
? parsed.data
: PlayMutationSchema.parse({
turn: input.turn,
actionKind: input.action.actionKind,
blocked: true,
blockedReason: "模型输出无法解析为有效的状态变更,本回合未推进世界状态。",
const language = input.language ?? "zh";
const actionKind = PlayActionIntentSchema.parse(input.action).actionKind;
const systemPrompt = await appendPromptPackGuidance(
buildWorldMutatorSystemPrompt(language),
{ promptId: "play.mutator", projectRoot: this.ctx.projectRoot },
);
const messages: { role: "system" | "user" | "assistant"; content: string }[] = [
{ role: "system", content: systemPrompt },
{ role: "user", content: buildWorldMutatorUserPrompt(input, language) },
];
// Empty output cannot count as a completed turn: otherwise prose advances
// while the canonical graph stays frozen. Give the model one repair turn,
// then expose a blocked no-op instead of silently splitting state and prose.
for (let attempt = 0; attempt < 2; attempt++) {
try {
const raw = await chatWithRetry(() => this.submitStructured(
messages,
WORLD_MUTATION_TOOL,
{ temperature: 0.25, maxTokens: 4096 },
));
const mutation = mutationFromStructuredResult(raw, input.turn, actionKind);
logDroppedMutationItems(raw, mutation, input.turn);
if (hasMutationResult(mutation)) return mutation;
} catch {
// One operation-level retry below. Transport retries remain in the Pi harness.
}
if (attempt === 0) {
messages.push({
role: "user",
content: language === "en"
? "No usable world result was submitted. Call submit_world_mutation with a summary and the concrete state, entity, relationship, or time changes. If the action cannot proceed, submit blocked=true with blockedReason."
: "刚才没有提交可用的世界结算。调用 submit_world_mutation,写明 summary 和具体的状态、实体、关系或时间变化;动作不能执行时提交 blocked=true 与 blockedReason。",
});
// Observability (#2): a dropped world item must not vanish silently. Log when
// the model proposed entities/edges/slots that parsing discarded — that is the
// difference between "the model wrote nothing" and "we threw its work away".
logDroppedMutationItems(raw, mutation, input.turn);
return { ...mutation, eventId: mutation.eventId || `evt-${input.turn}` };
}
}
return withHostMutationIdentity(PlayMutationSchema.parse({
blocked: true,
blockedReason: language === "en"
? "The model did not return a usable world-state transition. This turn did not advance."
: "模型没有返回可用的世界状态变更,本回合未推进。",
}), input.turn, actionKind);
}
}
function mutationFromStructuredResult(
raw: Record<string, unknown>,
turn: number,
actionKind: PlayActionIntent["actionKind"],
): PlayMutation {
return withHostMutationIdentity(PlayMutationSchema.parse({
summary: raw.summary,
timeAdvance: raw.timeAdvance,
entities: { upsert: raw.entities },
edges: {
upsert: raw.edges,
expire: Array.isArray(raw.expiredEdges)
? raw.expiredEdges.map((edge) => ({
...(edge as Record<string, unknown>),
validUntilEventId: `evt-${turn}`,
}))
: [],
},
stateSlots: { upsert: raw.stateSlots },
evidence: { transitions: raw.evidenceTransitions },
blocked: raw.blocked,
blockedReason: raw.blockedReason,
notes: raw.notes,
}), turn, actionKind);
}
function withHostMutationIdentity(
mutation: PlayMutation,
turn: number,
actionKind: PlayActionIntent["actionKind"],
): PlayMutation {
return PlayMutationSchema.parse({
...mutation,
eventId: `evt-${turn}`,
turn,
actionKind,
});
}
function hasMutationResult(mutation: PlayMutation): boolean {
return mutation.blocked
|| Boolean(mutation.summary.trim())
|| Boolean(mutation.timeAdvance)
|| mutation.entities.upsert.length > 0
|| mutation.edges.upsert.length > 0
|| mutation.edges.expire.length > 0
|| mutation.stateSlots.upsert.length > 0
|| mutation.evidence.transitions.length > 0
|| mutation.notes.length > 0;
}
function rawUpsertCount(field: unknown): number {
if (Array.isArray(field)) return field.length;
if (field && typeof field === "object" && Array.isArray((field as { upsert?: unknown }).upsert)) {
@@ -198,42 +351,12 @@ export class PlaySceneRendererAgent extends BaseAgent {
{ role: "system", content: systemPrompt },
{ role: "user", content: buildSceneRendererUserPrompt(input, language) },
];
// The renderer must NEVER throw — a hiccup here used to break the turn AND leave
// a half-committed world (event/state written before render). Retry transient
// upstream errors, ask once for strict JSON if the output wasn't, then fail open
// to the raw prose as the scene. (Bigger token budget so long literary scenes
// don't get truncated mid-JSON, which is itself a common parse failure.)
let lastContent = "";
for (let attempt = 0; attempt < 3; attempt++) {
let content = "";
try {
const response = await chatWithRetry(() => this.chat(messages, { temperature: 0.45, maxTokens: 4096 }));
content = response.content;
} catch {
break; // transient retries exhausted → fail open below
}
lastContent = content || lastContent;
const parsed = trySceneParse(content);
if (parsed) return parsed;
messages.push(
{ role: "assistant", content },
{
role: "user",
content: language === "en"
? 'That was not strict JSON. Output ONLY one JSON object {"sceneText": "...", "suggestedActions": ["..."]} and nothing else.'
: '上面不是严格 JSON。只输出一个 JSON 对象 {"sceneText": "...", "suggestedActions": ["..."]},不要任何其他文字。',
},
);
}
const proseFallback = lastContent
.trim()
.replace(/^```(?:json)?\s*/i, "")
.replace(/```\s*$/i, "")
.trim();
return {
sceneText: proseFallback || (language === "en" ? "(The moment holds, unresolved.)" : "(这一拍悬着,没有落定。)"),
suggestedActions: [],
};
const raw = await chatWithRetry(() => this.submitStructured(
messages,
PLAY_SCENE_RENDER_TOOL,
{ temperature: 0.45, maxTokens: 4096 },
));
return PlaySceneRenderSchema.parse(raw);
}
}
@@ -249,20 +372,19 @@ export class PlaySceneReconcilerAgent extends BaseAgent {
async reconcile(input: PlaySceneReconcileInput): Promise<PlayMutationInput> {
const language = input.language ?? "zh";
const eventId = `evt-${input.turn}`;
const empty = emptyReconciliation(input.turn, PlayActionIntentSchema.parse(input.action).actionKind);
const messages: { role: "system" | "user"; content: string }[] = [
const actionKind = PlayActionIntentSchema.parse(input.action).actionKind;
const empty = emptyReconciliation(input.turn, actionKind);
const messages: { role: "system" | "user" | "assistant"; content: string }[] = [
{ role: "system", content: buildSceneReconcilerSystemPrompt(language) },
{ role: "user", content: buildSceneReconcilerUserPrompt(input, language) },
];
try {
const response = await chatWithRetry(() => this.chat(messages, { temperature: 0.1, maxTokens: 2048 }));
const parsed = PlayMutationSchema.parse(parseJson(response.content));
return {
...parsed,
eventId: parsed.eventId || eventId,
turn: parsed.turn || input.turn,
actionKind: parsed.actionKind || PlayActionIntentSchema.parse(input.action).actionKind,
};
const raw = await chatWithRetry(() => this.submitStructured(
messages,
GRAPH_RECONCILIATION_TOOL,
{ temperature: 0.1, maxTokens: 2048 },
));
return mutationFromStructuredResult(raw, input.turn, actionKind);
} catch {
return empty;
}
@@ -290,19 +412,19 @@ function buildSceneReconcilerSystemPrompt(language: "zh" | "en"): string {
return [
"You reconcile an interactive-fiction scene with the world graph.",
"Compare the rendered prose against the already applied changes and current state summary.",
"If the prose introduced a concrete named object, clue, evidence, location, organization, or person that is not represented in the applied changes/current state, output ONLY supplemental PlayMutation entries for those missing graph facts.",
"Do not rewrite prose. Do not invent facts that are not in the rendered scene. If nothing is missing, output an empty PlayMutation with empty arrays.",
"If the prose introduced a concrete named object, clue, evidence, location, organization, or person that is not represented in the applied changes/current state, submit ONLY those missing graph facts.",
"Do not rewrite prose. Do not invent facts that are not in the rendered scene. If nothing is missing, submit empty arrays.",
"Use the same eventId/turn/actionKind. For tangible things the player now physically holds, add a holding edge from actor_player with value.role=\"holding\"; if the target is evidence/clue/claim/proof_chain rather than an item, also set value.physical=true. Observed phenomena or learned facts are not holdings.",
"Output strict JSON matching PlayMutation.",
"Call submit_graph_reconciliation once. The host supplies eventId, turn, and actionKind.",
].join("\n");
}
return [
"你负责把互动小说正文和世界图谱对齐。",
"对照已经应用的本回合变化、当前状态摘要和最终正文。",
"如果正文里出现了具体且具名的新物件、线索、证据、地点、组织或人物,但它还没有体现在已应用变化/当前状态里,只输出这些缺失图谱事实的补充 PlayMutation。",
"不要改正文,不要发明正文没有的事实。没有缺失就输出空的 PlayMutation,各数组留空。",
"如果正文里出现了具体且具名的新物件、线索、证据、地点、组织或人物,但它还没有体现在已应用变化/当前状态里,只提交这些缺失图谱事实。",
"不要改正文,不要发明正文没有的事实。没有缺失就提交空数组。",
"沿用同一个 eventId/turn/actionKind。玩家获得或拿在手里的实物,需要补一条 actor_player 指向该实体、value.role=\"holding\" 的 edge;如果目标是 evidence/clue/claim/proof_chain 而不是 item,还要设置 value.physical=true。观察到的现象或知道的信息不是持有物。",
"输出严格 JSON,必须符合 PlayMutation。",
"调用一次 submit_graph_reconciliationeventId、turn、actionKind 由宿主补入。",
].join("\n");
}
@@ -418,7 +540,7 @@ function buildWorldMutatorSystemPrompt(language: "zh" | "en"): string {
"Only use evidence.transitions for the evidence lifecycle when this world is genuinely an investigation/mystery; otherwise leave it empty.",
"If the player's action is invalid or information is insufficient, set blocked=true and write blockedReason.",
"Time is a synchronization axis, not a fixed tick. For every non-opening turn, set timeAdvance with: elapsed = the natural-language duration spent by this action; anchor = the world time/phase after the action if the world has a clock, season, phase, day/night, retreat period, deadline, or other temporal anchor; rationale = why this duration is right; synchronized = what relevant NPCs/places/pressures changed during the same elapsed time. A glance may pass seconds, a trip half a day, cultivation three years — obey the user's world contract; never invent a universal turn length.",
"Output strict JSON matching PlayMutation: eventId, turn, actionKind, summary, timeAdvance, entities, edges, stateSlots, evidence, blocked, blockedReason, notes.",
"Call submit_world_mutation once with summary, timeAdvance, entities, edges, stateSlots, evidenceTransitions, blocked, blockedReason, and notes. The host supplies eventId, turn, and actionKind.",
"The following is only a JSON-shape example. Do not reuse its labels, names, or story facts in the actual world; the reserved player id actor_player is the only example id you must keep for the player entity:",
`{"eventId":"evt-1","turn":1,"actionKind":"look","summary":"The player-character finds a sample clue and a sample key.","timeAdvance":{"elapsed":"a few breaths","anchor":"still in the same rain-soaked minute","rationale":"The player only examined the immediate scene.","synchronized":["The counterpart notices the pause but does not act openly yet."]},"entities":{"upsert":[{"id":"actor_player","type":"actor","label":"player-character","summary":"Reserved player entity id; replace label, summary, and status with the current world's player identity.","status":"alert","updatedEventId":"evt-1"},{"id":"actor_counterpart","type":"actor","label":"counterpart","summary":"Placeholder for a relevant person in the current world; replace with the real roster id/label.","status":"guarded","updatedEventId":"evt-1"},{"id":"evidence_sample_clue","type":"evidence","label":"sample clue","summary":"A tangible clue discovered this turn; replace with a real object from the scene.","status":"seen","updatedEventId":"evt-1"},{"id":"item_sample_key","type":"item","label":"sample key","summary":"A tangible item collected this turn; replace with a real object from the scene.","status":"collected","updatedEventId":"evt-1"}]},"edges":{"upsert":[{"fromId":"actor_player","type":"suspicious_of","toId":"actor_counterpart","value":{"role":"relation"}},{"fromId":"actor_player","type":"holds","toId":"item_sample_key","value":{"role":"holding"}},{"fromId":"actor_player","type":"holds","toId":"evidence_sample_clue","value":{"role":"holding","physical":true}}]},"stateSlots":{"upsert":[{"id":"slot_sample_timer","kind":"timer","label":"sample timer","value":3,"updatedEventId":"evt-1"}]}}`,
].join("\n");
@@ -442,7 +564,7 @@ function buildWorldMutatorSystemPrompt(language: "zh" | "en"): string {
"只有当这个世界确实是调查/推理题材时,才用 evidence.transitions 走证据生命周期;其他题材留空即可。",
"如果玩家动作无效或信息不足,blocked=true 并写 blockedReason。",
"时间是世界同步轴,不是固定 tick。每个非开场回合都要写 timeAdvance:elapsed=本动作按语义经过了多久;anchor=动作结束后世界处在什么时间/阶段(若本局有钟点、昼夜、季节、闭关期、期限、潮汐、巡逻节奏等时间锚点);rationale=为什么是这段时间;synchronized=同一段时间里相关人物/地点/压力发生了什么同步变化。看一眼可能几息,赶路可能半天,闭关可能三年——遵守用户的世界契约,绝不要发明统一回合长度。",
"输出严格 JSON,必须符合 PlayMutationeventId, turn, actionKind, summary, timeAdvance, entities, edges, stateSlots, evidence, blocked, blockedReason, notes。",
"调用一次 submit_world_mutation,提交 summarytimeAdvanceentitiesedgesstateSlotsevidenceTransitions、blockedblockedReasonnoteseventId、turn、actionKind 由宿主补入。",
"下面的范例只示结构,不得复用范例里的名称、人名或剧情事实;唯一必须保留的示例 id 是玩家本人 actor_player",
`{"eventId":"evt-1","turn":1,"actionKind":"look","summary":"玩家角色发现了一个示例线索和一个示例道具。","timeAdvance":{"elapsed":"几息","anchor":"仍在同一个雨夜片刻里","rationale":"玩家只是贴近观察眼前物件,没有离开现场。","synchronized":["相关人物注意到玩家停顿,但还没有公开阻拦。"]},"entities":{"upsert":[{"id":"actor_player","type":"actor","label":"玩家角色","summary":"玩家本人固定实体 id;实际输出只替换 label、summary、status 为本局玩家身份。","status":"警觉","updatedEventId":"evt-1"},{"id":"actor_counterpart","type":"actor","label":"相关人物","summary":"当前世界中相关人物的占位示例;实际输出必须替换为本局真实实体。","status":"戒备","updatedEventId":"evt-1"},{"id":"evidence_sample_clue","type":"evidence","label":"示例线索","summary":"本回合发现的实物线索示例;实际输出必须替换为场景里的真实物件。","status":"已发现","updatedEventId":"evt-1"},{"id":"item_sample_key","type":"item","label":"示例钥匙","summary":"本回合获得的实物道具示例;实际输出必须替换为场景里的真实物件。","status":"已收集","updatedEventId":"evt-1"}]},"edges":{"upsert":[{"fromId":"actor_player","type":"怀疑","toId":"actor_counterpart","value":{"role":"relation"}},{"fromId":"actor_player","type":"持有","toId":"item_sample_key","value":{"role":"holding"}},{"fromId":"actor_player","type":"持有","toId":"evidence_sample_clue","value":{"role":"holding","physical":true}}]},"stateSlots":{"upsert":[{"id":"slot_sample_timer","kind":"timer","label":"示例倒计时","value":3,"updatedEventId":"evt-1"}]}}`,
].join("\n");
@@ -484,6 +606,8 @@ export function buildSceneRendererSystemPrompt(mode: "open" | "guided" = "open",
const base = [
"You are an interactive-fiction scene-response author.",
"Write the response only from the already-applied state; do not overturn the reducer's results.",
"The scene must visibly carry out every completed part of the player's action recorded in Applied changes before writing its aftermath. Do not skip a requested examination, conversation, movement, or use of an item and jump straight to a reaction or decision point.",
"The world setting and authoritative pre-action context preserve identity, ownership, relationships, persistent counts, and established facts. Keep them unchanged unless Applied changes explicitly update them.",
"Concrete new objects, clues, evidence, locations, organizations, or named people can only appear if they are already present in Applied changes or Current state summary. If the prose needs a new concrete thing, it must have been created by the mutator first; otherwise describe mood, pressure, or an unnamed detail instead.",
"If Current state summary includes a Time section, treat elapsed and anchor as canonical. Render the scene after exactly that elapsed interval, at that resulting world time/phase, and include the synchronized pressure/character movement naturally in prose. Do not invent another clock reading, another elapsed amount, or a fixed tick label.",
"sceneText is narrative prose only. Choice hints belong only in suggestedActions, never as an A/B/C or bullet menu inside sceneText.",
@@ -496,6 +620,8 @@ export function buildSceneRendererSystemPrompt(mode: "open" | "guided" = "open",
const base = [
"你是互动小说场景回应作者。",
"你只能根据已经应用后的状态写回应,不要推翻 reducer 结果。",
"正文必须先把「已应用的本回合变化」里已经完成的玩家动作逐项写出来,再写动作后的反应;不得漏掉用户要求的观察、交谈、移动或使用物件,直接跳到余波或下一个抉择点。",
"世界设定和本回合前的权威上下文保存人物身份、归属、关系、持续数量与既成事实;除非「已应用的本回合变化」明确修改,否则必须保持不变。",
"具体的新物件、线索、证据、地点、组织、具名人物,只能来自「已应用的本回合变化」或「当前状态摘要」。如果正文需要一个新的具体东西,它必须先由 mutator 建成实体;否则只写氛围、压力或不具名的细节。",
"如果当前状态摘要里有 Time/时间段,elapsed 和 anchor 是权威时间:正文必须按这段经过时长、这个动作后的世界时间/阶段来写,并把同步发生的压力、人物移动、远处变化自然溶进正文。不得另写一个钟点、另写一段经过时长,也不要写成固定 tick、回合标签或 UI 提示。",
"sceneText 只写叙事散文。动作提示只能放在 suggestedActions,绝不能在 sceneText 里写 A/B/C 或项目符号菜单。",
@@ -508,9 +634,11 @@ export function buildSceneRendererSystemPrompt(mode: "open" | "guided" = "open",
function buildSceneRendererUserPrompt(input: PlaySceneRenderInput, language: "zh" | "en"): string {
const premise = input.worldPremise?.trim();
const context = input.context?.trim();
if (language === "en") {
return [
...(premise ? ["World setting (always obey):", premise, ""] : []),
...(context ? ["Authoritative context before this action:", context, ""] : []),
"Player's words:",
input.input,
"",
@@ -527,6 +655,7 @@ function buildSceneRendererUserPrompt(input: PlaySceneRenderInput, language: "zh
}
return [
...(premise ? ["世界设定(始终遵守):", premise, ""] : []),
...(context ? ["本回合前的权威上下文:", context, ""] : []),
"玩家原话:",
input.input,
"",
+50 -8
View File
@@ -42,6 +42,7 @@ export interface PlaySceneRendererLike {
readonly render: (input: {
readonly input: string;
readonly action: PlayActionIntentInput;
readonly context?: string;
readonly mutationSummary: string;
readonly stateBrief: string;
readonly replayContext?: string;
@@ -101,6 +102,13 @@ export interface PlayOpeningSeedResult {
readonly mutation: PlayMutation;
}
export class PlayOpeningSeedError extends Error {
constructor(message: string) {
super(message);
this.name = "PlayOpeningSeedError";
}
}
export class PlayRunner {
private readonly store: PlayStore;
private readonly db: PlayReducerDB;
@@ -143,7 +151,7 @@ export class PlayRunner {
}): Promise<PlayOpeningSeedResult | null> {
await this.store.ensureRun(this.options.worldId, this.options.runId);
const existing = readGraphSnapshot(this.db);
if ((existing?.entities.length ?? 0) > 0 || (existing?.stateSlots.length ?? 0) > 0) {
if (isOpeningGraphReady(existing)) {
return null;
}
@@ -177,13 +185,41 @@ export class PlayRunner {
turn: 0,
actionKind: "look",
});
const stateBrief = renderStateBrief({ action, mutation: normalized });
const finalMutation = this.sceneReconciler
? mergePlayMutations(normalized, PlayMutationSchema.parse(await this.sceneReconciler.reconcile({
turn: 0,
input: buildOpeningSeedInput({
sceneText: input.sceneText,
suggestedActions: input.suggestedActions ?? [],
language,
premise: worldContext,
}),
action,
mutation: normalized,
sceneText: input.sceneText,
context,
stateBrief,
language,
worldPremise: worldContext,
})))
: normalized;
seedPlayGraph({
db: this.db,
mutation: normalized,
mutation: finalMutation,
});
await this.store.writeProjection(this.options.worldId, this.options.runId, "projections/state.md", renderStateBrief({ action, mutation: normalized }));
return { mutation: normalized };
const seededGraph = readGraphSnapshot(this.db);
if (finalMutation.blocked || !isOpeningGraphReady(seededGraph)) {
throw new PlayOpeningSeedError(
finalMutation.blockedReason
|| (language === "en"
? "The opening scene did not produce a usable player/world graph. Retry world creation."
: "开场没有生成可用的玩家与世界图谱,请重试创建互动世界。"),
);
}
await this.store.writeProjection(this.options.worldId, this.options.runId, "projections/state.md", renderStateBrief({ action, mutation: finalMutation }));
return { mutation: finalMutation };
}
async step(input: string, options: { readonly replayContext?: string } = {}): Promise<PlayStepResult> {
@@ -211,13 +247,13 @@ export class PlayRunner {
}));
const stateBrief = renderStateBrief({ action, mutation });
// Render BEFORE any commit. The renderer is fail-open (never throws), but the
// ordering still matters: nothing about this turn (db mutation, event, state,
// scene, transcript) is persisted until the scene is in hand — so a turn is
// all-or-nothing and can never leave a "state advanced but tool failed" half-state.
// Render BEFORE any commit. Nothing about this turn (db mutation, event, state,
// scene, transcript) is persisted until a valid scene is in hand, so rendering
// failures remain retryable and cannot create a half-committed turn.
const render = await this.sceneRenderer.render({
input: rawInput,
action,
context,
mutationSummary: mutation.summary || mutation.blockedReason,
stateBrief,
replayContext: options.replayContext,
@@ -383,6 +419,12 @@ export class PlayRunner {
}
}
function isOpeningGraphReady(graph: PlayGraphSnapshot | null): boolean {
if (!graph) return false;
return graph.entities.some((entity) => entity.id === "actor_player")
&& graph.entities.some((entity) => entity.id !== "actor_player");
}
function buildOpeningSeedInput(input: {
readonly sceneText: string;
readonly suggestedActions: readonly string[];
+5 -1
View File
@@ -1,4 +1,4 @@
import { appendFile, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
import { appendFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import { randomUUID } from "node:crypto";
import { join, normalize, sep } from "node:path";
import { z } from "zod";
@@ -85,6 +85,10 @@ export class PlayStore {
return world;
}
async removeWorld(worldId: string): Promise<void> {
await rm(this.worldDir(worldId), { recursive: true, force: true });
}
async updateWorld(worldId: string, patch: Partial<Pick<PlayWorld, "premise" | "worldContract" | "visualContract" | "mode">>): Promise<PlayWorld> {
const current = await this.loadWorld(worldId);
if (!current) {
@@ -46,6 +46,8 @@ const RAW_BUILTIN_PROMPTS: BuiltinPrompt[] = [
"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.",
"Treat exact placement and timing instructions as literal acceptance criteria: if the user says the first line, opening beat, final beat, or a named scene must contain something, put it there rather than merely including it later.",
"Reuse supplied hook ids and narrative promises. Do not rename an existing unresolved case, swap its actors or numbers without instruction, or open a duplicate hook for the same promise.",
].join("\n"),
},
{
@@ -55,6 +57,7 @@ const RAW_BUILTIN_PROMPTS: BuiltinPrompt[] = [
content: [
"You are InkOS's long-form reviser.",
"Fix the chapter according to audit issues while preserving established facts and the chapter goal.",
"Repair every critical author-intent and canon issue before polishing prose. Exact placement failures, wrong actors or numbers, and duplicate hook promises are not style suggestions.",
"If a repair requires changing higher-level state, surface that need instead of silently rewriting canon.",
].join("\n"),
},
@@ -65,6 +68,8 @@ const RAW_BUILTIN_PROMPTS: BuiltinPrompt[] = [
content: [
"You are InkOS's continuity and quality auditor.",
"Check whether the chapter follows protected intent, hard facts, active hooks, proportions, and craft requirements.",
"Before scoring style, enumerate every explicit must, must-not, exact placement, named actor, number, and hook constraint from protected intent and verify each one against the chapter.",
"A missing exact-placement requirement or a renamed, contradicted, or duplicated supplied hook is a critical structural failure, not a minor style issue.",
"Report unresolved issues plainly; do not mark a failed chapter as fixed.",
].join("\n"),
},
@@ -60,6 +60,7 @@ export function buildShortFictionOutlineSystemPrompt(language: ShortFictionLangu
"Content comes first: the title, the opening, the pressure on the protagonist, the evidence/relationship/identity leverage, the escalation chain, the reversal chain, and the payoff landing must be strong enough to carry a single-pass full draft.",
"Do not over-structure and do not output JSON/YAML. Write human-readable Markdown, but the chapter plan must be dense enough that a writer can draft the whole story in one pass.",
"A short defaults to 12-18 chapters at roughly 600-800 words per chapter. The story must be complete — not the first five chapters of a novel starter kit.",
"Return only the final story plan for the writer; do not place task restatement, analysis, or internal reasoning in the deliverable.",
].join("\n");
}
return [
@@ -68,6 +69,7 @@ export function buildShortFictionOutlineSystemPrompt(language: ShortFictionLangu
"目标是内容优先:标题、开篇、人物压力、证据/关系/身份杠杆、升级链、反转链和回报落点必须能支撑一次写完整篇。",
"不要过度结构化,不要输出 JSON/YAML。用人能读的 Markdown,但章节方案必须足够密,写手拿到后能直接一次写完。",
"短篇默认 12-18 章,每章约 900-1200 字。故事要完整,不是长篇前 5 章启动包。",
"回复只包含交付给写手的最终故事方案;不要把分析过程、任务复述或内部推理写进交付物。",
].join("\n");
}
+98 -11
View File
@@ -8,7 +8,12 @@ import {
type RuntimeStateDelta,
} from "../models/runtime-state.js";
import type { Fact, StoredHook, StoredSummary } from "./memory-db.js";
import { bootstrapStructuredStateFromMarkdown, parseCurrentStateFacts } from "./state-bootstrap.js";
import {
bootstrapStructuredStateFromMarkdown,
parseChapterSummariesMarkdown,
parseCurrentStateFacts,
parsePendingHooksMarkdown,
} from "./state-bootstrap.js";
import { renderChapterSummariesProjection, renderCurrentStateProjection, renderHooksProjection } from "./state-projections.js";
import { applyRuntimeStateDelta, type RuntimeStateSnapshot } from "./state-reducer.js";
import { validateRuntimeState } from "./state-validator.js";
@@ -45,15 +50,65 @@ export async function loadRuntimeStateSnapshot(bookDir: string): Promise<Runtime
chapterSummaries,
};
const issues = validateRuntimeState(snapshot);
if (issues.length > 0) {
const summary = issues
.map((issue) => `${issue.code}${issue.path ? `@${issue.path}` : ""}`)
.join(", ");
throw new Error(`Invalid persisted runtime state: ${summary}`);
return validateLoadedSnapshot(snapshot, "persisted runtime state");
}
export async function loadRuntimeStateSnapshotAtChapter(params: {
readonly bookDir: string;
readonly chapterNumber: number;
readonly language: "zh" | "en";
}): Promise<RuntimeStateSnapshot> {
const snapshotDir = join(
params.bookDir,
"story",
"snapshots",
String(params.chapterNumber),
);
const stateDir = join(snapshotDir, "state");
const [manifest, currentState, hooks, chapterSummaries] = await Promise.all([
readJsonOrNull(join(stateDir, "manifest.json"), StateManifestSchema),
readJsonOrNull(join(stateDir, "current_state.json"), CurrentStateStateSchema),
readJsonOrNull(join(stateDir, "hooks.json"), HooksStateSchema),
readJsonOrNull(join(stateDir, "chapter_summaries.json"), ChapterSummariesStateSchema),
]);
if (manifest && currentState && hooks && chapterSummaries) {
return validateLoadedSnapshot(
{ manifest, currentState, hooks, chapterSummaries },
`runtime snapshot at chapter ${params.chapterNumber}`,
);
}
return snapshot;
const [currentStateMarkdown, hooksMarkdown, summariesMarkdown] = await Promise.all([
readFile(join(snapshotDir, "current_state.md"), "utf-8"),
readFile(join(snapshotDir, "pending_hooks.md"), "utf-8"),
readFile(join(snapshotDir, "chapter_summaries.md"), "utf-8").catch(() => ""),
]);
const markdownSnapshot: RuntimeStateSnapshot = {
manifest: StateManifestSchema.parse({
schemaVersion: 2,
language: params.language,
lastAppliedChapter: params.chapterNumber,
projectionVersion: 1,
migrationWarnings: [
`runtime snapshot ${params.chapterNumber} reconstructed from markdown`,
],
}),
currentState: CurrentStateStateSchema.parse({
chapter: params.chapterNumber,
facts: parseCurrentStateFacts(currentStateMarkdown, params.chapterNumber),
}),
hooks: HooksStateSchema.parse({
hooks: parsePendingHooksMarkdown(hooksMarkdown),
}),
chapterSummaries: ChapterSummariesStateSchema.parse({
rows: parseChapterSummariesMarkdown(summariesMarkdown),
}),
};
return validateLoadedSnapshot(
markdownSnapshot,
`markdown runtime snapshot at chapter ${params.chapterNumber}`,
);
}
export async function buildRuntimeStateArtifacts(params: {
@@ -61,14 +116,32 @@ export async function buildRuntimeStateArtifacts(params: {
readonly delta: RuntimeStateDelta;
readonly language: "zh" | "en";
readonly allowReapply?: boolean;
readonly allowNewHooks?: boolean;
}): Promise<RuntimeStateArtifacts> {
const snapshot = await loadRuntimeStateSnapshot(params.bookDir);
const { resolvedDelta } = arbitrateRuntimeStateDeltaHooks({
hooks: snapshot.hooks.hooks,
return buildRuntimeStateArtifactsFromSnapshot({
snapshot,
delta: params.delta,
language: params.language,
allowReapply: params.allowReapply,
allowNewHooks: params.allowNewHooks,
});
}
export function buildRuntimeStateArtifactsFromSnapshot(params: {
readonly snapshot: RuntimeStateSnapshot;
readonly delta: RuntimeStateDelta;
readonly language: "zh" | "en";
readonly allowReapply?: boolean;
readonly allowNewHooks?: boolean;
}): RuntimeStateArtifacts {
const { resolvedDelta } = arbitrateRuntimeStateDeltaHooks({
hooks: params.snapshot.hooks.hooks,
delta: params.delta,
allowNewHooks: params.allowNewHooks,
});
const next = applyRuntimeStateDelta({
snapshot,
snapshot: params.snapshot,
delta: resolvedDelta,
allowReapply: params.allowReapply,
});
@@ -85,6 +158,20 @@ export async function buildRuntimeStateArtifacts(params: {
};
}
function validateLoadedSnapshot(
snapshot: RuntimeStateSnapshot,
label: string,
): RuntimeStateSnapshot {
const issues = validateRuntimeState(snapshot);
if (issues.length > 0) {
const summary = issues
.map((issue) => `${issue.code}${issue.path ? `@${issue.path}` : ""}`)
.join(", ");
throw new Error(`Invalid ${label}: ${summary}`);
}
return snapshot;
}
export async function saveRuntimeStateSnapshot(
bookDir: string,
snapshot: RuntimeStateSnapshot,
+4 -7
View File
@@ -12,7 +12,7 @@ import {
type StateManifest,
} from "../models/runtime-state.js";
import type { Fact, StoredHook } from "./memory-db.js";
import { normalizeHookPayoffTiming } from "../utils/hook-lifecycle.js";
import { normalizeHookPayoffTiming, resolveHookStatusAlias } from "../utils/hook-lifecycle.js";
import {
inferFactSubject,
isCurrentChapterLabel,
@@ -575,12 +575,9 @@ export function resolveContiguousChapterPrefix(chapterNumbers: ReadonlyArray<num
}
function normalizeHookStatus(value: string | undefined, warnings: string[], hookId: string): HookStatus {
const normalized = (value ?? "").trim().toLowerCase();
if (!normalized) return "open";
if (/(resolved|closed|done|paid[_ -]?off|已回收|回收|完成|已解决|已兑现|兑现)/i.test(normalized)) return "resolved";
if (/(deferred|paused|hold|dormant|inactive|unplanted|unseeded|not[_ -]?started|not[_ -]?active|搁置|延后|延期|暂缓|休眠|未激活|未启动|待启动|未推进|尚未推进)/i.test(normalized)) return "deferred";
if (/(confirmed[_ -]?hit|confirmed|advanced|progressing|progress|active|pressured|命中|已确认命中|已推进|推进|进行中|持续推进|重大推进)/i.test(normalized)) return "progressing";
if (/(open|pending|seeded|planted|待定|未回收|已埋|已种下|已铺垫)/i.test(normalized)) return "open";
const normalized = resolveHookStatusAlias(value);
if (normalized) return normalized;
if (!(value ?? "").trim()) return "open";
appendWarning(warnings, `${hookId}:status normalized from "${value ?? ""}" to "open"`);
return "open";
}
+2 -13
View File
@@ -94,17 +94,10 @@ function applyHookOps(hooksState: HooksState, delta: RuntimeStateDelta): HooksSt
expectedPayoff: hook.expectedPayoff,
notes: hook.notes,
},
activeHooks: [...hooksById.values()].filter((candidate) => candidate.status !== "resolved"),
});
if (!admission.admit && admission.reason === "duplicate_family") {
const matchedHookId = admission.matchedHookId;
const existing = matchedHookId ? hooksById.get(matchedHookId) : undefined;
if (!existing) {
throw new Error(`duplicate active hook family: ${hook.hookId} overlaps ${admission.matchedHookId}`);
}
hooksById.set(existing.hookId, mergeDuplicateHookFamily(existing, hook));
continue;
if (!admission.admit) {
throw new Error(`invalid hook ${hook.hookId}: ${admission.reason}`);
}
hooksById.set(hook.hookId, { ...hook });
@@ -144,10 +137,6 @@ function applyHookOps(hooksState: HooksState, delta: RuntimeStateDelta): HooksSt
};
}
function mergeDuplicateHookFamily(existing: HookRecord, incoming: HookRecord): HookRecord {
return mergeHookRecord(existing, incoming);
}
function mergeHookRecord(existing: HookRecord, incoming: HookRecord): HookRecord {
const expectedPayoff = preferRicherText(existing.expectedPayoff, incoming.expectedPayoff);
const notes = preferRicherText(existing.notes, incoming.notes);
+11 -133
View File
@@ -9,7 +9,7 @@ import { evaluateHookAdmission } from "./hook-governance.js";
import { resolveHookPayoffTiming } from "./hook-lifecycle.js";
export interface HookArbiterDecision {
readonly action: "created" | "mapped" | "mentioned" | "rejected";
readonly action: "created" | "rejected";
readonly reason: string;
readonly hookId?: string;
readonly candidate: NewHookCandidate;
@@ -22,6 +22,7 @@ interface PendingHookCandidate extends NewHookCandidate {
export function arbitrateRuntimeStateDeltaHooks(params: {
readonly hooks: ReadonlyArray<HookRecord>;
readonly delta: RuntimeStateDelta;
readonly allowNewHooks?: boolean;
}): {
readonly resolvedDelta: RuntimeStateDelta;
readonly decisions: ReadonlyArray<HookArbiterDecision>;
@@ -53,51 +54,20 @@ export function arbitrateRuntimeStateDeltaHooks(params: {
}
for (const candidate of [...fallbackCandidates, ...delta.newHookCandidates]) {
const activeHooks = workingHooks.filter((hook) => hook.status !== "resolved");
if (params.allowNewHooks === false) {
decisions.push({
action: "rejected",
reason: "new_hooks_disabled",
candidate,
});
continue;
}
const admission = evaluateHookAdmission({
candidate,
activeHooks,
});
if (!admission.admit) {
if (admission.reason === "duplicate_family" && admission.matchedHookId) {
const matched = workingHooks.find((hook) => hook.hookId === admission.matchedHookId);
if (!matched) {
decisions.push({
action: "rejected",
reason: "duplicate_family_without_match",
candidate,
});
continue;
}
if (isPureRestatement(candidate, matched)) {
if (!upsertsById.has(matched.hookId) && !resolves.includes(matched.hookId) && !defers.includes(matched.hookId)) {
mentions.add(matched.hookId);
}
decisions.push({
action: "mentioned",
reason: "restated_existing_family",
hookId: matched.hookId,
candidate,
});
continue;
}
const base = upsertsById.get(matched.hookId) ?? matched;
const mapped = mergeCandidateIntoExistingHook(base, candidate, delta.chapter);
upsertsById.set(mapped.hookId, mapped);
mentions.delete(mapped.hookId);
replaceWorkingHook(workingHooks, mapped);
decisions.push({
action: "mapped",
reason: "duplicate_family_with_novelty",
hookId: matched.hookId,
candidate,
});
continue;
}
decisions.push({
action: "rejected",
reason: admission.reason,
@@ -145,26 +115,6 @@ export function arbitrateRuntimeStateDeltaHooks(params: {
};
}
function mergeCandidateIntoExistingHook(
existing: HookRecord,
candidate: NewHookCandidate,
chapter: number,
): HookRecord {
return {
...existing,
type: preferRicherText(existing.type, candidate.type),
status: existing.status === "resolved" ? "resolved" : "progressing",
lastAdvancedChapter: Math.max(existing.lastAdvancedChapter, chapter),
expectedPayoff: preferRicherText(existing.expectedPayoff, candidate.expectedPayoff),
payoffTiming: resolveHookPayoffTiming({
payoffTiming: candidate.payoffTiming ?? existing.payoffTiming,
expectedPayoff: preferRicherText(existing.expectedPayoff, candidate.expectedPayoff),
notes: preferRicherText(existing.notes, candidate.notes),
}),
notes: preferRicherText(existing.notes, candidate.notes),
};
}
function createCanonicalHook(params: {
readonly candidate: PendingHookCandidate;
readonly chapter: number;
@@ -222,32 +172,6 @@ function slugifyHookStem(value: string): string {
return stem || "hook";
}
function isPureRestatement(candidate: NewHookCandidate, existing: HookRecord): boolean {
const candidateText = normalizeText([
candidate.type,
candidate.expectedPayoff,
candidate.notes,
].join(" "));
const existingText = normalizeText([
existing.type,
existing.expectedPayoff,
existing.notes,
].join(" "));
if (!candidateText) return true;
if (candidateText === existingText) return true;
const candidateTerms = extractTerms(candidateText);
const existingTerms = extractTerms(existingText);
const novelTerms = [...candidateTerms].filter((term) => !existingTerms.has(term));
const candidateChinese = extractChineseBigrams(candidateText);
const existingChinese = extractChineseBigrams(existingText);
const novelChinese = [...candidateChinese].filter((term) => !existingChinese.has(term));
return novelTerms.length === 0 && novelChinese.length < 2;
}
function replaceWorkingHook(workingHooks: HookRecord[], hook: HookRecord): void {
const index = workingHooks.findIndex((candidate) => candidate.hookId === hook.hookId);
if (index >= 0) {
@@ -268,52 +192,6 @@ function uniqueStrings(values: ReadonlyArray<string>): string[] {
return [...new Set(values.map((value) => value.trim()).filter(Boolean))];
}
function preferRicherText(primary: string, fallback: string): string {
const left = primary.trim();
const right = fallback.trim();
if (!left) return right;
if (!right) return left;
if (left === right) return left;
return right.length > left.length ? right : left;
}
function normalizeText(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fff]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function extractTerms(value: string): Set<string> {
const english = value
.split(" ")
.map((term) => term.trim())
.filter((term) => term.length >= 4)
.filter((term) => !STOP_WORDS.has(term));
const chinese = value.match(/[\u4e00-\u9fff]{2,6}/g) ?? [];
return new Set([...english, ...chinese]);
}
function extractChineseBigrams(value: string): Set<string> {
const segments = value.match(/[\u4e00-\u9fff]+/g) ?? [];
const terms = new Set<string>();
for (const segment of segments) {
if (segment.length < 2) {
continue;
}
for (let index = 0; index <= segment.length - 2; index += 1) {
terms.add(segment.slice(index, index + 2));
}
}
return terms;
}
const STOP_WORDS = new Set([
"that",
"this",
+2 -97
View File
@@ -12,8 +12,7 @@ export interface HookAdmissionCandidate {
export interface HookAdmissionDecision {
readonly admit: boolean;
readonly reason: "admit" | "missing_type" | "missing_payoff_signal" | "duplicate_family";
readonly matchedHookId?: string;
readonly reason: "admit" | "missing_type" | "missing_payoff_signal";
}
export function collectStaleHookDebt(params: {
@@ -52,9 +51,8 @@ export function collectStaleHookDebt(params: {
export function evaluateHookAdmission(params: {
readonly candidate: HookAdmissionCandidate;
readonly activeHooks: ReadonlyArray<HookRecord>;
}): HookAdmissionDecision {
const candidateType = normalizeText(params.candidate.type);
const candidateType = params.candidate.type.trim();
if (!candidateType) {
return {
admit: false,
@@ -74,50 +72,6 @@ export function evaluateHookAdmission(params: {
};
}
const candidateNormalized = normalizeText([
params.candidate.type,
params.candidate.expectedPayoff ?? "",
params.candidate.payoffTiming ?? "",
params.candidate.notes ?? "",
].join(" "));
const candidateTerms = extractTerms(candidateNormalized);
const candidateChineseBigrams = extractChineseBigrams(candidateNormalized);
for (const hook of params.activeHooks) {
const activeNormalized = normalizeText([
hook.type,
hook.expectedPayoff,
hook.payoffTiming ?? "",
hook.notes,
].join(" "));
if (candidateNormalized === activeNormalized) {
return {
admit: false,
reason: "duplicate_family",
matchedHookId: hook.hookId,
};
}
if (candidateType !== normalizeText(hook.type)) {
continue;
}
const activeTerms = extractTerms(activeNormalized);
const overlap = [...candidateTerms].filter((term) => activeTerms.has(term));
const activeChineseBigrams = extractChineseBigrams(activeNormalized);
const chineseOverlap = [...candidateChineseBigrams].filter((term) =>
activeChineseBigrams.has(term),
);
if (overlap.length >= 2 || chineseOverlap.length >= 3) {
return {
admit: false,
reason: "duplicate_family",
matchedHookId: hook.hookId,
};
}
}
return {
admit: true,
reason: "admit",
@@ -148,52 +102,3 @@ export function classifyHookDisposition(params: {
return "none";
}
function normalizeText(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fff]+/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function extractTerms(value: string): Set<string> {
const english = value
.split(" ")
.map((term) => term.trim())
.filter((term) => term.length >= 4)
.filter((term) => !STOP_WORDS.has(term));
const chinese = value.match(/[\u4e00-\u9fff]{2,6}/g) ?? [];
return new Set([...english, ...chinese]);
}
function extractChineseBigrams(value: string): Set<string> {
const segments = value.match(/[\u4e00-\u9fff]+/g) ?? [];
const terms = new Set<string>();
for (const segment of segments) {
if (segment.length < 2) {
continue;
}
for (let index = 0; index <= segment.length - 2; index += 1) {
terms.add(segment.slice(index, index + 2));
}
}
return terms;
}
const STOP_WORDS = new Set([
"that",
"this",
"with",
"from",
"into",
"still",
"just",
"have",
"will",
"reveal",
]);
+30 -6
View File
@@ -1,4 +1,4 @@
import type { HookPayoffTiming } from "../models/runtime-state.js";
import type { HookPayoffTiming, HookStatus } from "../models/runtime-state.js";
import type { StoredHook } from "../state/memory-db.js";
import {
HOOK_ACTIVITY_THRESHOLDS,
@@ -11,11 +11,35 @@ import {
export const DEFAULT_HOOK_LOOKAHEAD_CHAPTERS = 3;
export function normalizeStoredHookStatus(status: string): "resolved" | "deferred" | "progressing" | "open" {
if (/^(resolved|closed|done|已回收|已解决)$/i.test(status.trim())) return "resolved";
if (/^(deferred|paused|hold|dormant|sleeping|延后|延期|搁置|暂缓|未开启|待开启|未启动|待启动|待推进)$/i.test(status.trim())) return "deferred";
if (/^(progressing|advanced|重大推进|持续推进)$/i.test(status.trim())) return "progressing";
return "open";
const HOOK_STATUS_ALIASES: ReadonlyMap<string, HookStatus> = new Map([
...[
"resolved", "closed", "done", "paid_off", "paid-off", "paid off",
"已回收", "回收", "完成", "已解决", "已兑现", "兑现",
].map((value) => [value, "resolved"] as const),
...[
"deferred", "paused", "hold", "dormant", "sleeping", "inactive",
"unplanted", "unseeded", "not_started", "not-started", "not started",
"not_active", "not-active", "not active", "搁置", "延后", "延期", "暂缓",
"休眠", "未激活", "未开启", "待开启", "未启动", "待启动", "未推进",
"尚未推进", "待推进",
].map((value) => [value, "deferred"] as const),
...[
"progressing", "advanced", "progress", "active", "pressured", "confirmed",
"confirmed_hit", "confirmed-hit", "confirmed hit", "命中", "已确认命中", "已推进",
"推进", "进行中", "持续推进", "重大推进",
].map((value) => [value, "progressing"] as const),
...[
"open", "pending", "seeded", "planted", "待定", "未回收", "已埋", "已种下", "已铺垫",
].map((value) => [value, "open"] as const),
]);
export function resolveHookStatusAlias(status: string | undefined | null): HookStatus | undefined {
const normalized = status?.trim().toLowerCase();
return normalized ? HOOK_STATUS_ALIASES.get(normalized) : undefined;
}
export function normalizeStoredHookStatus(status: string): HookStatus {
return resolveHookStatusAlias(status) ?? "open";
}
export function filterActiveHooks(hooks: ReadonlyArray<StoredHook>): StoredHook[] {
+18 -4
View File
@@ -12,6 +12,7 @@ import {
filterActiveHooks,
isFuturePlannedHook,
isHookWithinChapterWindow,
normalizeStoredHookStatus,
} from "./hook-lifecycle.js";
import {
parseChapterSummariesMarkdown,
@@ -131,6 +132,10 @@ export async function retrieveMemorySelection(params: {
// promoted/core/dependency metadata, which is load-bearing for hook debt.
const hooks = structuredHooks?.hooks ?? parsePendingHooksMarkdown(hooksMarkdown);
const activeHooks = filterActiveHooks(hooks);
// Dormant architect seeds are not active debt, but they remain searchable
// canon. A chapter can explicitly activate one of them; excluding deferred
// rows from retrieval makes the planner invent a duplicate hook instead.
const searchableHooks = hooks.filter((hook) => normalizeStoredHookStatus(hook.status) !== "resolved");
const summaries = structuredSummaries?.rows ?? parseChapterSummariesMarkdown(
await readFile(join(storyDir, "chapter_summaries.md"), "utf-8").catch(() => ""),
@@ -150,7 +155,7 @@ export async function retrieveMemorySelection(params: {
STORY_MEMORY_SCOPE,
buildMemorySearchDocuments({
summaries,
hooks: effectiveActiveHooks,
hooks: searchableHooks,
facts,
volumeSummaries: parsedVolumeSummaries,
}),
@@ -171,7 +176,12 @@ export async function retrieveMemorySelection(params: {
return {
summaries: selectRelevantSummaries(summaries, params.chapterNumber, rankScores),
hooks: selectRelevantHooks(effectiveActiveHooks, rankScores, params.chapterNumber),
hooks: selectRelevantHooks(
searchableHooks,
effectiveActiveHooks,
rankScores,
params.chapterNumber,
),
activeHooks: effectiveActiveHooks,
recyclableHooks: computeRecyclableHooks(effectiveActiveHooks, params.chapterNumber),
facts: selectRelevantFacts(facts, rankScores),
@@ -412,9 +422,11 @@ function selectRelevantSummaries(
function selectRelevantHooks(
hooks: ReadonlyArray<StoredHook>,
activeHooks: ReadonlyArray<StoredHook>,
rankScores: ReadonlyMap<string, number>,
chapterNumber: number,
): StoredHook[] {
const activeHookIds = new Set(activeHooks.map((hook) => hook.hookId));
const ranked = hooks
.map((hook) => {
const retrievalScore = rankScores.get(hookDocumentId(hook.hookId)) ?? 0;
@@ -424,11 +436,12 @@ function selectRelevantHooks(
retrieved: retrievalScore > 0,
};
})
.filter((entry) => entry.retrieved || isUnresolvedHook(entry.hook.status));
.filter((entry) => entry.retrieved || activeHookIds.has(entry.hook.hookId));
const primary = ranked
.filter((entry) =>
entry.retrieved || isHookWithinChapterWindow(entry.hook, chapterNumber, 5),
entry.retrieved
|| (activeHookIds.has(entry.hook.hookId) && isHookWithinChapterWindow(entry.hook, chapterNumber, 5)),
)
.sort((left, right) => right.score - left.score || right.hook.lastAdvancedChapter - left.hook.lastAdvancedChapter)
.slice(0, 6);
@@ -437,6 +450,7 @@ function selectRelevantHooks(
const stale = ranked
.filter((entry) =>
!selectedIds.has(entry.hook.hookId)
&& activeHookIds.has(entry.hook.hookId)
&& !isFuturePlannedHook(entry.hook, chapterNumber)
&& isUnresolvedHook(entry.hook.status),
)
+2 -1
View File
@@ -1,6 +1,7 @@
import type { Fact, StoredHook, StoredSummary } from "../state/memory-db.js";
import {
localizeHookPayoffTiming,
normalizeStoredHookStatus,
normalizeHookPayoffTiming,
resolveHookPayoffTiming,
} from "./hook-lifecycle.js";
@@ -284,7 +285,7 @@ function parsePendingHookRow(row: ReadonlyArray<string | undefined>): StoredHook
hookId: normalizeHookId(row[0]),
startChapter: parseStrictChapterInteger(row[1]),
type: row[2] ?? "",
status: row[3] ?? "open",
status: normalizeStoredHookStatus(row[3] ?? "open"),
lastAdvancedChapter: parseStrictChapterInteger(row[4]),
expectedPayoff: row[5] ?? "",
payoffTiming,
+345 -2
View File
@@ -64,6 +64,52 @@ const createShortFictionRunToolMock = vi.fn((_pipeline: unknown, _root: string,
},
})),
}));
function derivativeCreationToolMock(
name: string,
creationKind: string,
bookId: string,
title: string,
) {
return vi.fn((_pipeline: unknown, projectRoot: string) => ({
name,
execute: vi.fn(async (_id: string, params: Record<string, unknown>) => {
await writeCompleteBookFixture(projectRoot, bookId, title);
return {
content: [{ type: "text", text: `Created ${title}.` }],
details: {
kind: "book_created",
creationKind,
bookId,
title,
params,
},
};
}),
}));
}
const createFanficBookToolMock = derivativeCreationToolMock("fanfic_create", "fanfic", "霜港来信", "霜港来信");
const createContinuationImportToolMock = vi.fn((
_pipeline: unknown,
_activeBookId: string | null,
projectRoot: string,
) => ({
name: "continuation_import",
execute: vi.fn(async (_id: string, params: Record<string, unknown>) => {
await writeCompleteBookFixture(projectRoot, "雾港续章", "雾港续章");
return {
content: [{ type: "text", text: "Created 雾港续章." }],
details: {
kind: "book_created",
creationKind: "continuation",
bookId: "雾港续章",
title: "雾港续章",
params,
},
};
}),
}));
const createSpinoffBookToolMock = derivativeCreationToolMock("spinoff_create", "spinoff", "雨夜旧账", "雨夜旧账");
const createImitationBookToolMock = derivativeCreationToolMock("imitation_create", "imitation", "纸灯新案", "纸灯新案");
type ServicePresetMock = {
providerFamily: "openai" | "anthropic";
baseUrl: string;
@@ -218,6 +264,8 @@ vi.mock("@actalk/inkos-core", async (importOriginal) => {
return task();
});
createAgentContext = vi.fn(() => ({}));
initBook = initBookMock;
runRadar = runRadarMock;
planChapter = planChapterMock;
@@ -297,8 +345,27 @@ vi.mock("@actalk/inkos-core", async (importOriginal) => {
abortAgentSession: abortAgentSessionMock,
createSubAgentTool: actual.createSubAgentTool,
createShortFictionRunTool: createShortFictionRunToolMock,
createFanficBookTool: createFanficBookToolMock,
createContinuationImportTool: createContinuationImportToolMock,
createSpinoffBookTool: createSpinoffBookToolMock,
createImitationBookTool: createImitationBookToolMock,
createGenerateCoverTool: actual.createGenerateCoverTool,
createPlayStartTool: actual.createPlayStartTool,
createPlayStartTool: (
pipeline: InstanceType<typeof MockPipelineRunner>,
projectRoot: string,
sessionId: string,
playMode?: "open" | "guided",
options: Parameters<typeof actual.createPlayStartTool>[4] = {},
) => actual.createPlayStartTool(pipeline as never, projectRoot, sessionId, playMode, {
...options,
runnerFactory: ({ db }) => ({
seedOpening: async () => {
db.upsertEntity({ id: "actor_player", type: "actor", label: "玩家", summary: "当前玩家。" });
db.upsertEntity({ id: "location_opening", type: "location", label: "开场地点", summary: "第一幕所在地点。" });
return null;
},
}),
}),
PlayRunner: MockPlayRunner,
ConsolidatorAgent: MockConsolidatorAgent,
PlayStore: actual.PlayStore,
@@ -425,6 +492,9 @@ describe("createStudioServer daemon lifecycle", () => {
await writeFile(join(root, "inkos.json"), JSON.stringify(projectConfig, null, 2), "utf-8");
schedulerStartMock.mockReset();
initBookMock.mockReset();
initBookMock.mockImplementation(async (book: { id: string; title: string }) => {
await writeCompleteBookFixture(root, book.id, book.title);
});
runRadarMock.mockReset();
planChapterMock.mockReset();
composeChapterMock.mockReset();
@@ -530,6 +600,10 @@ describe("createStudioServer daemon lifecycle", () => {
})),
});
createShortFictionRunToolMock.mockClear();
createFanficBookToolMock.mockClear();
createContinuationImportToolMock.mockClear();
createSpinoffBookToolMock.mockClear();
createImitationBookToolMock.mockClear();
chatCompletionMock.mockReset();
chatCompletionMock.mockResolvedValue({
content: "pong",
@@ -3055,6 +3129,21 @@ describe("createStudioServer daemon lifecycle", () => {
await expect(response.json()).resolves.toEqual({ ok: true, aborted: true });
});
it("supports aborting only the cached chat agent during session navigation", async () => {
abortAgentSessionMock.mockReturnValueOnce(true);
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(cloneProjectConfig() as never, root);
const response = await app.request(
"http://localhost/api/v1/sessions/agent-session-1/abort?scope=chat",
{ method: "POST" },
);
expect(response.status).toBe(200);
expect(abortAgentSessionMock).toHaveBeenCalledWith(root, "agent-session-1");
await expect(response.json()).resolves.toEqual({ ok: true, aborted: true });
});
it("routes /api/agent through runAgentSession and returns response + sessionId", async () => {
runAgentSessionMock.mockImplementationOnce(async (config: { onEvent?: (event: unknown) => void }) => {
config.onEvent?.({
@@ -3215,6 +3304,134 @@ describe("createStudioServer daemon lifecycle", () => {
});
});
it("executes confirmed derivative works as typed tools and binds their real book artifacts", async () => {
const session = {
sessionId: "derivative-session",
bookId: null,
sessionKind: "chat",
title: null,
messages: [],
events: [],
draftRounds: [],
createdAt: 1,
updatedAt: 1,
};
loadBookSessionMock.mockResolvedValue(session);
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(cloneProjectConfig() as never, root);
const cases = [
{
intent: "fanfic_init",
payload: { fanficCreate: { title: "霜港来信", sourceText: "正典片段", sourceName: "霜港" } },
factory: createFanficBookToolMock,
tool: "fanfic_create",
bookId: "霜港来信",
},
{
intent: "continuation_import",
payload: { continuationImport: { title: "雾港续章", sourcePath: ".inkos/uploads/novel.txt" } },
factory: createContinuationImportToolMock,
tool: "continuation_import",
bookId: "雾港续章",
},
{
intent: "spinoff_create",
payload: { spinoffCreate: { title: "雨夜旧账", parentBookId: "harbor", direction: "老船工视角" } },
factory: createSpinoffBookToolMock,
tool: "spinoff_create",
bookId: "雨夜旧账",
},
{
intent: "style_imitation",
payload: { imitationCreate: { title: "纸灯新案", referenceText: "参考片段", storyIdea: "原创县城悬疑" } },
factory: createImitationBookToolMock,
tool: "imitation_create",
bookId: "纸灯新案",
},
] as const;
for (const [index, item] of cases.entries()) {
loadBookSessionMock.mockResolvedValue({ ...session, sessionId: `derivative-session-${index}` });
const response = await app.request("http://localhost/api/v1/agent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
instruction: `确认执行 ${item.intent}`,
sessionId: `derivative-session-${index}`,
sessionKind: "chat",
actionSource: "button",
requestedIntent: item.intent,
actionPayload: item.payload,
}),
});
const responseBody = await response.clone().json();
expect(response.status, `${item.intent}: ${JSON.stringify(responseBody)}`).toBe(200);
expect(runAgentSessionMock).not.toHaveBeenCalled();
expect(item.factory).toHaveBeenCalled();
const json = await response.json() as {
session: { activeBookId?: string };
details: { toolExecutions: Array<{ tool: string; details?: Record<string, unknown> }> };
};
expect(json.session.activeBookId).toBe(item.bookId);
expect(json.details.toolExecutions[0]).toMatchObject({
tool: item.tool,
details: { kind: "book_created", bookId: item.bookId },
});
expect(migrateBookSessionMock).toHaveBeenCalledWith(root, `derivative-session-${index}`, item.bookId);
}
});
it("does not bind a confirmed derivative result when its book artifact is missing", async () => {
loadBookSessionMock.mockResolvedValue({
sessionId: "missing-derivative-session",
bookId: null,
sessionKind: "chat",
title: null,
messages: [],
events: [],
draftRounds: [],
createdAt: 1,
updatedAt: 1,
});
createFanficBookToolMock.mockImplementationOnce(() => ({
name: "fanfic_create",
execute: vi.fn(async () => ({
content: [{ type: "text", text: "Claimed success without files." }],
details: {
kind: "book_created",
creationKind: "fanfic",
bookId: "不存在的同人",
title: "不存在的同人",
params: { sourceText: "正典片段" },
},
})),
}));
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(cloneProjectConfig() as never, root);
const response = await app.request("http://localhost/api/v1/agent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
instruction: "确认创建不存在的同人",
sessionId: "missing-derivative-session",
sessionKind: "chat",
actionSource: "button",
requestedIntent: "fanfic_init",
actionPayload: {
fanficCreate: { title: "不存在的同人", sourceText: "正典片段" },
},
}),
});
expect(response.status).toBe(500);
await expect(response.json()).resolves.toMatchObject({
error: { code: "BOOK_CREATION_INCOMPLETE" },
});
expect(migrateBookSessionMock).not.toHaveBeenCalled();
});
it("infers English before directly executing a confirmed short action", async () => {
const shortSession = {
sessionId: "short-en-session",
@@ -3361,6 +3578,7 @@ describe("createStudioServer daemon lifecycle", () => {
});
});
await writeCompleteBookFixture(root, "雨夜旧账", "雨夜旧账");
resolveInitBook();
const response = await pendingResponse;
expect(response.status).toBe(200);
@@ -3466,6 +3684,7 @@ describe("createStudioServer daemon lifecycle", () => {
},
});
await writeCompleteBookFixture(root, "雨夜账本", "雨夜账本");
resolveInitBook();
await pendingResponse;
});
@@ -3756,6 +3975,7 @@ describe("createStudioServer daemon lifecycle", () => {
// 第二个任务没有真正启动
expect(initBookMock).toHaveBeenCalledTimes(1);
await writeCompleteBookFixture(root, "第一本书", "第一本书");
resolveInitBook();
await pendingTask;
// 第一个任务不受影响,正常完成
@@ -3791,8 +4011,9 @@ describe("createStudioServer daemon lifecycle", () => {
return sessionRecord;
});
// 任务本体拖一拍,保证第二个请求做检查时第一个任务还在运行中
initBookMock.mockImplementation(async () => {
initBookMock.mockImplementation(async (book: { id: string; title: string }) => {
await new Promise((resolve) => setTimeout(resolve, 25));
await writeCompleteBookFixture(root, book.id, book.title);
});
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(cloneProjectConfig() as never, root);
@@ -3884,6 +4105,7 @@ describe("createStudioServer daemon lifecycle", () => {
expect(config.suppressProductionTools).toBe(true);
expect(agentCall?.[1]).toBe("现在在写吗?");
await writeCompleteBookFixture(root, "并行验证", "并行验证");
resolveInitBook();
await pendingTask;
@@ -4028,6 +4250,7 @@ describe("createStudioServer daemon lifecycle", () => {
expect(parallelChatLog.sessionId).toBe("tagged-log-session");
expect(parallelChatLog.executionId).toBeUndefined();
await writeCompleteBookFixture(root, "日志打标验证", "日志打标验证");
resolveInitBook();
const taskResponse = await pendingTask;
expect(taskResponse.status).toBe(200);
@@ -4795,6 +5018,53 @@ describe("createStudioServer daemon lifecycle", () => {
});
});
it("keeps a running production task alive when only the parallel chat scope is aborted", async () => {
let resolveWrite!: (value: unknown) => void;
writeNextChapterMock.mockImplementationOnce(() => new Promise((resolve) => {
resolveWrite = resolve;
}));
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(cloneProjectConfig() as never, root);
const pendingResponse = app.request("http://localhost/api/v1/agent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
instruction: "继续",
activeBookId: "demo-book",
sessionId: "agent-session-1",
sessionKind: "book",
actionSource: "quick-action",
requestedIntent: "write_next",
}),
});
await vi.waitFor(async () => {
const task = await loadStudioTaskSnapshot(root, "agent-session-1");
expect(task?.execution.status).toBe("running");
});
const abortResponse = await app.request(
"http://localhost/api/v1/sessions/agent-session-1/abort?scope=chat",
{ method: "POST" },
);
expect(abortResponse.status).toBe(200);
expect(pipelineAbortSignals.at(-1)?.aborted).toBe(false);
resolveWrite({
chapterNumber: 3,
title: "Still Running",
wordCount: 1800,
revised: false,
status: "ready-for-review",
auditResult: { passed: true, issues: [], summary: "ok" },
});
const response = await pendingResponse;
expect(response.status).toBe(200);
await expect(loadStudioTaskSnapshot(root, "agent-session-1")).resolves.toMatchObject({
execution: { status: "completed", completedAt: expect.any(Number) },
});
});
it("rejects a second production task with 409 while write-next is still running", async () => {
let resolveWrite!: (value: unknown) => void;
writeNextChapterMock.mockImplementationOnce(() => new Promise((resolve) => {
@@ -5706,10 +5976,83 @@ describe("createStudioServer daemon lifecycle", () => {
sessionId: "agent-session-1",
sessionKind: "play",
},
details: {
toolExecutions: [expect.objectContaining({
id: "play-step-1",
tool: "play_step",
status: "completed",
details: expect.objectContaining({
kind: "play_turn_advanced",
worldId: "world-1",
runId: "main",
}),
})],
},
});
expect(chatCompletionMock).not.toHaveBeenCalled();
});
it("does not duplicate a play scene in the final HTTP response after the tool card owns it", async () => {
loadBookSessionMock.mockResolvedValue({
sessionId: "agent-session-1",
bookId: null,
sessionKind: "play",
playMode: "open",
title: null,
messages: [],
events: [],
draftRounds: [],
createdAt: 1,
updatedAt: 1,
});
runAgentSessionMock.mockImplementationOnce(async (config: { onEvent?: (event: unknown) => void }) => {
config.onEvent?.({
type: "tool_execution_start",
toolCallId: "play-step-duplicate",
toolName: "play_step",
args: { input: "检查封条" },
});
config.onEvent?.({
type: "tool_execution_end",
toolCallId: "play-step-duplicate",
toolName: "play_step",
isError: false,
result: {
content: [{ type: "text", text: "封条背面有一道新鲜划痕。" }],
details: {
kind: "play_turn_advanced",
worldId: "world-1",
runId: "main",
sceneText: "封条背面有一道新鲜划痕。",
},
},
});
return {
responseText: "封条背面有一道新鲜划痕。",
messages: [{ role: "assistant", content: "封条背面有一道新鲜划痕。" }],
};
});
const { createStudioServer } = await import("./server.js");
const app = createStudioServer(cloneProjectConfig() as never, root);
const response = await app.request("http://localhost/api/v1/agent", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
instruction: "检查封条",
sessionId: "agent-session-1",
sessionKind: "play",
playMode: "open",
}),
});
expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
response: "",
session: { sessionId: "agent-session-1", sessionKind: "play" },
});
});
it("migrates and exposes a book created by architect even when the final agent text is empty", async () => {
await writeCompleteBookFixture(root, "new-book", "New Book");
const orphanSession = {
+124 -30
View File
@@ -91,6 +91,10 @@ import {
createShortFictionRunTool,
createStoryboardCreationTool,
createTranslationCreateTool,
createFanficBookTool,
createContinuationImportTool,
createSpinoffBookTool,
createImitationBookTool,
createSubAgentTool,
createDraftStructureTool,
createConnectChoiceTool,
@@ -129,6 +133,7 @@ import {
type SessionKind,
type AgentSessionAttachment,
} from "@actalk/inkos-core";
import { isConfirmedProductionAction } from "../shared/confirmed-production.js";
import { access, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
import { isSafeBookId } from "./safety.js";
@@ -213,6 +218,10 @@ const TOOL_LABELS: Record<string, BilingualLabel> = {
storyboard_create: { zh: "分镜创作", en: "Storyboard creation" },
interactive_film_create: { zh: "互动影游", en: "Interactive film" },
translation_create: { zh: "翻译项目", en: "Translation" },
fanfic_create: { zh: "同人创作", en: "Fanfiction" },
continuation_import: { zh: "导入续写", en: "Continuation import" },
spinoff_create: { zh: "番外创作", en: "Side story" },
imitation_create: { zh: "仿写创作", en: "Style imitation" },
generate_cover: { zh: "生成封面", en: "Cover generation" },
play_edit: { zh: "编辑互动世界", en: "Edit interactive world" },
play_start: { zh: "启动互动世界", en: "Start interactive world" },
@@ -1156,6 +1165,14 @@ function suppressManualTextForTool(exec: CollectedToolExec): boolean {
|| exec.tool === "interactive_film_create";
}
function hasSuccessfulToolOwnedResponse(execs: ReadonlyArray<CollectedToolExec>): boolean {
return execs.some((exec) =>
exec.status === "completed"
&& !isLikelyFailedToolResult(exec)
&& suppressManualTextForTool(exec)
);
}
function manualToolAssistantMessage(
responseText: string,
exec: CollectedToolExec,
@@ -1191,30 +1208,6 @@ function manualToolAppendOptions(sessionKind: SessionKind, exec: CollectedToolEx
};
}
function isConfirmedProductionAction(args: {
readonly actionSource: ActionSource;
readonly requestedIntent?: RequestedIntent;
}): boolean {
const confirmedSource = args.actionSource === "button"
|| args.actionSource === "slash"
|| (args.actionSource === "quick-action" && args.requestedIntent === "write_next");
return confirmedSource
&& (
args.requestedIntent === "write_next"
|| args.requestedIntent === "create_book"
|| args.requestedIntent === "short_run"
|| args.requestedIntent === "script_create"
|| args.requestedIntent === "storyboard_create"
|| args.requestedIntent === "interactive_film_create"
|| args.requestedIntent === "translation_create"
|| args.requestedIntent === "play_start"
|| args.requestedIntent === "generate_cover"
|| args.requestedIntent === "draft_structure"
|| args.requestedIntent === "connect_choice"
|| args.requestedIntent === "remove_node"
);
}
function requirePayloadText(value: string | undefined, message: string): string {
const text = value?.trim();
if (!text) {
@@ -1268,6 +1261,10 @@ async function executeConfirmedProductionAction(args: {
| ReturnType<typeof createStoryboardCreationTool>
| ReturnType<typeof createInteractiveFilmCreationTool>
| ReturnType<typeof createTranslationCreateTool>
| ReturnType<typeof createFanficBookTool>
| ReturnType<typeof createContinuationImportTool>
| ReturnType<typeof createSpinoffBookTool>
| ReturnType<typeof createImitationBookTool>
| ReturnType<typeof createPlayStartTool>
| ReturnType<typeof createDraftStructureTool>
| ReturnType<typeof createConnectChoiceTool>
@@ -1416,6 +1413,88 @@ async function executeConfirmedProductionAction(args: {
...(payload?.title ? { title: payload.title } : {}),
...(payload?.segmentMaxChars ? { segmentMaxChars: payload.segmentMaxChars } : {}),
};
} else if (args.requestedIntent === "fanfic_init") {
const payload = actionPayload?.fanficCreate;
const title = requirePayloadText(payload?.title, pick(lang, "确认创建同人缺少书名,请补充后重新确认。", "The fanfiction confirmation is missing a title."));
if (!payload?.sourceText?.trim() && !payload?.sourcePath?.trim()) {
throw new ApiError(400, "CONFIRMED_ACTION_PAYLOAD_INCOMPLETE", pick(lang, "创建同人需要原作资料或上传文件。", "Fanfiction creation requires source material or an uploaded file."));
}
tool = createFanficBookTool(args.pipeline, args.root, {
defaultSkills: productionSkills("longWriting"),
});
params = {
title,
...(payload.sourceText ? { sourceText: payload.sourceText } : {}),
...(payload.sourcePath ? { sourcePath: payload.sourcePath } : {}),
...(payload.sourceName ? { sourceName: payload.sourceName } : {}),
mode: payload.mode ?? "canon",
...(payload.genre ? { genre: payload.genre } : {}),
...(payload.platform ? { platform: payload.platform } : {}),
language: payload.language ?? lang,
...(payload.targetChapters ? { targetChapters: payload.targetChapters } : {}),
...(payload.chapterWordCount ? { chapterWordCount: payload.chapterWordCount } : {}),
};
} else if (args.requestedIntent === "continuation_import") {
const payload = actionPayload?.continuationImport;
const sourcePath = requirePayloadText(payload?.sourcePath, pick(lang, "导入续写需要上传文件或章节目录。", "Continuation import requires an uploaded file or chapter directory."));
const targetBookId = payload?.bookId ?? args.bookId ?? undefined;
if (!targetBookId && !payload?.title?.trim()) {
throw new ApiError(400, "CONFIRMED_ACTION_PAYLOAD_INCOMPLETE", pick(lang, "导入续写需要选择已有书籍或填写新书名。", "Continuation import requires an existing book or a new title."));
}
tool = createContinuationImportTool(args.pipeline, args.bookId, args.root, {
defaultSkills: productionSkills("longWriting"),
});
params = {
...(targetBookId ? { bookId: targetBookId } : {}),
...(payload?.title ? { title: payload.title } : {}),
sourcePath,
...(payload?.splitPattern ? { splitPattern: payload.splitPattern } : {}),
...(payload?.resumeFrom ? { resumeFrom: payload.resumeFrom } : {}),
...(payload?.genre ? { genre: payload.genre } : {}),
...(payload?.platform ? { platform: payload.platform } : {}),
language: payload?.language ?? lang,
...(payload?.targetChapters ? { targetChapters: payload.targetChapters } : {}),
...(payload?.chapterWordCount ? { chapterWordCount: payload.chapterWordCount } : {}),
};
} else if (args.requestedIntent === "spinoff_create") {
const payload = actionPayload?.spinoffCreate;
const title = requirePayloadText(payload?.title, pick(lang, "确认创建番外缺少书名。", "The side-story confirmation is missing a title."));
const parentBookId = requirePayloadText(payload?.parentBookId ?? args.bookId ?? undefined, pick(lang, "创建番外需要指定正传书籍。", "Side-story creation requires a parent book."));
tool = createSpinoffBookTool(args.pipeline, args.root, {
defaultSkills: productionSkills("longWriting"),
});
params = {
title,
parentBookId,
...(payload?.direction ? { direction: payload.direction } : {}),
...(payload?.genre ? { genre: payload.genre } : {}),
...(payload?.platform ? { platform: payload.platform } : {}),
...(payload?.language ? { language: payload.language } : {}),
...(payload?.targetChapters ? { targetChapters: payload.targetChapters } : {}),
...(payload?.chapterWordCount ? { chapterWordCount: payload.chapterWordCount } : {}),
};
} else if (args.requestedIntent === "style_imitation") {
const payload = actionPayload?.imitationCreate;
const title = requirePayloadText(payload?.title, pick(lang, "确认创建仿写缺少书名。", "The imitation confirmation is missing a title."));
const storyIdea = requirePayloadText(payload?.storyIdea, pick(lang, "仿写需要一个原创故事方向。", "Style imitation requires an original story idea."));
if (!payload?.referenceText?.trim() && !payload?.referencePath?.trim()) {
throw new ApiError(400, "CONFIRMED_ACTION_PAYLOAD_INCOMPLETE", pick(lang, "仿写需要参考文本或上传文件。", "Style imitation requires reference text or an uploaded file."));
}
tool = createImitationBookTool(args.pipeline, args.root, {
defaultSkills: productionSkills("longWriting"),
});
params = {
title,
storyIdea,
...(payload.referenceText ? { referenceText: payload.referenceText } : {}),
...(payload.referencePath ? { referencePath: payload.referencePath } : {}),
...(payload.sourceName ? { sourceName: payload.sourceName } : {}),
...(payload.genre ? { genre: payload.genre } : {}),
...(payload.platform ? { platform: payload.platform } : {}),
language: payload.language ?? lang,
...(payload.targetChapters ? { targetChapters: payload.targetChapters } : {}),
...(payload.chapterWordCount ? { chapterWordCount: payload.chapterWordCount } : {}),
};
} else if (args.requestedIntent === "play_start") {
const payload = actionPayload?.playStart;
const title = requirePayloadText(payload?.title, pick(lang, "确认启动互动世界缺少标题,请重新生成确认卡。", "The interactive world start confirmation is missing a title. Regenerate the confirmation card."));
@@ -1654,7 +1733,7 @@ function resolveArchitectBookIdFromArgs(args?: Record<string, unknown>): string
function resolveCreatedBookIdFromToolExecs(execs: ReadonlyArray<CollectedToolExec>): string | null {
for (let i = execs.length - 1; i >= 0; i -= 1) {
const exec = execs[i];
if (exec.tool !== "sub_agent" || exec.agent !== "architect" || exec.status !== "completed") continue;
if (exec.status !== "completed") continue;
const details = exec.details as { kind?: unknown; bookId?: unknown } | undefined;
if (details?.kind === "book_created" && typeof details.bookId === "string" && details.bookId.trim()) {
@@ -4529,11 +4608,12 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
app.post("/api/v1/sessions/:sessionId/abort", async (c) => {
const sessionId = c.req.param("sessionId");
const controller = await findRunningTaskController(sessionId);
const chatOnly = c.req.query("scope") === "chat";
const controller = chatOnly ? undefined : await findRunningTaskController(sessionId);
controller?.abort();
const taskAborted = Boolean(controller);
const aborted = abortAgentSession(root, sessionId) || taskAborted;
broadcast("agent:aborted", { sessionId, aborted });
broadcast("agent:aborted", { sessionId, aborted, scope: chatOnly ? "chat" : "all" });
return c.json({ ok: true, aborted });
});
@@ -4771,7 +4851,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
: client;
// Only a structured action request can start a production task. Free text
// always stays in the Pi agent loop; the host never infers intent from prose.
const confirmedIntent = requestedIntent && isConfirmedProductionAction({ actionSource, requestedIntent })
const confirmedIntent = requestedIntent && isConfirmedProductionAction(actionSource, requestedIntent)
? requestedIntent
: undefined;
// 任务的 execution id 在构建 pipeline 之前生成并传入 executionIdForSSE
@@ -4873,9 +4953,15 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
});
let createdBookId: string | null = null;
if (exec.tool === "sub_agent" && exec.agent === "architect" && exec.status === "completed") {
if (exec.status === "completed") {
createdBookId = resolveCreatedBookIdFromToolExecs([exec]);
if (createdBookId) {
if (!await completeBookExists(join(root, "books", createdBookId))) {
const message = pick(surfaceLanguage, "创作工具返回了建书结果,但磁盘上的书籍工件不完整。", "The creation tool returned a book result, but the on-disk book artifact is incomplete.");
bookCreateStatus.set(createdBookId, { status: "error", error: message });
broadcast("book:error", { bookId: createdBookId, sessionId: bookSession.sessionId, error: message });
throw new ApiError(500, "BOOK_CREATION_INCOMPLETE", message);
}
try {
const migratedSession = await migrateBookSession(root, bookSession.sessionId, createdBookId);
if (migratedSession) {
@@ -4925,6 +5011,13 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
bookCreateStatus.set(pendingBookId, { status: "error", error: message });
broadcast("book:error", { bookId: pendingBookId, sessionId: streamSessionId, error: message });
}
if (error instanceof ApiError) {
broadcast("agent:error", { instruction, activeBookId: agentBookId, sessionId: bookSession.sessionId, sessionKind, error: message });
return c.json({
error: { code: error.code, message: error.message },
response: error.message,
}, error.status as 400 | 401 | 403 | 404 | 409 | 413 | 415 | 422 | 429 | 500 | 502 | 503);
}
if (error instanceof ConfirmedActionExecutionError) {
// 指令已在任务开始时写入 transcript,失败时同样只补助手工具消息。
await appendSessionMessagesUnlessDeleted(root, bookSession.sessionId, [
@@ -5179,6 +5272,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
sessionKind: responseSessionKind,
...(createdBookId ?? bookSession.bookId ? { activeBookId: createdBookId ?? bookSession.bookId } : {}),
},
details: { toolExecutions: collectedToolExecs },
});
}
@@ -5202,7 +5296,7 @@ export function createStudioServer(initialConfig: ProjectConfig, root: string, o
broadcast("agent:complete", { instruction, activeBookId, sessionId: bookSession.sessionId, sessionKind: responseSessionKind });
return c.json({
response: result.responseText,
response: hasSuccessfulToolOwnedResponse(collectedToolExecs) ? "" : result.responseText,
session: {
sessionId: bookSession.sessionId,
sessionKind: responseSessionKind,
+11 -5
View File
@@ -314,11 +314,11 @@ export function Sidebar({ nav, activePage, sse, t }: {
<CreateItem icon={<Clapperboard size={16} />} label={t("nav.createScript")} onClick={() => launchProjectMode("script")} />
<CreateItem icon={<Rows3 size={16} />} label={t("nav.createStoryboard")} onClick={() => launchProjectMode("storyboard")} />
<CreateItem icon={<Film size={16} />} label={t("nav.createInteractiveFilm")} onClick={() => launchProjectMode("interactive-film")} />
<CreateItem icon={<Feather size={16} />} label={t("nav.createFanfic")} onClick={() => nav.toImport("fanfic")} />
<CreateItem icon={<BookCopy size={16} />} label={t("nav.createSpinoff")} onClick={() => nav.toImport("spinoff")} />
<CreateItem icon={<Wand2 size={16} />} label={t("nav.createImitation")} onClick={() => nav.toImport("imitation")} />
<CreateItem icon={<FileInput size={16} />} label={t("nav.createContinuation")} onClick={() => nav.toImport("chapters")} />
<CreateItem icon={<Languages size={16} />} label={t("nav.createTranslation")} active={activePage === "translation"} onClick={nav.toTranslation} />
<CreateItem icon={<Feather size={16} />} label={t("nav.createFanfic")} onClick={handleCreateProjectChatSession} />
<CreateItem icon={<BookCopy size={16} />} label={t("nav.createSpinoff")} onClick={handleCreateProjectChatSession} />
<CreateItem icon={<Wand2 size={16} />} label={t("nav.createImitation")} onClick={handleCreateProjectChatSession} />
<CreateItem icon={<FileInput size={16} />} label={t("nav.createContinuation")} onClick={handleCreateProjectChatSession} />
<CreateItem icon={<Languages size={16} />} label={t("nav.createTranslation")} onClick={handleCreateProjectChatSession} />
<CreateItem icon={<GitBranch size={16} />} label={t("nav.createBranching")} onClick={() => launchProjectMode("play", "guided")} />
<CreateItem icon={<Gamepad2 size={16} />} label={t("nav.createFree")} onClick={() => launchProjectMode("play", "open")} />
</div>
@@ -605,6 +605,12 @@ export function Sidebar({ nav, activePage, sse, t }: {
</span>
</div>
<div className="space-y-1">
<SidebarItem
label={t("nav.translation")}
icon={<Languages size={16} />}
active={activePage === "translation"}
onClick={nav.toTranslation}
/>
<SidebarItem
label={t("nav.style")}
icon={<Wand2 size={16} />}
@@ -145,7 +145,6 @@ export interface ProposedActionDetails {
readonly execId: string;
readonly action: ChatRequestedIntent;
readonly targetSessionKind: ChatSessionKind;
readonly targetRoute?: "import:fanfic" | "import:chapters" | "import:canon" | "import:spinoff" | "import:imitation" | "style";
readonly sameSession?: boolean;
readonly title?: string;
readonly summary?: string;
@@ -322,19 +321,153 @@ function ChapterContextTracePreview({ exec }: { exec: ToolExecution }) {
);
}
function proposedTargetRouteField(record: Record<string, unknown>): ProposedActionDetails["targetRoute"] {
const value = stringField(record, "targetRoute");
if (
value === "import:fanfic"
|| value === "import:chapters"
|| value === "import:canon"
|| value === "import:spinoff"
|| value === "import:imitation"
|| value === "style"
) {
return value;
}
return undefined;
interface ChapterRevisionIssueDetails {
readonly severity: string;
readonly category: string;
readonly description: string;
readonly suggestion?: string;
}
interface ChapterRevisionDetails {
readonly chapterNumber?: number;
readonly applied: boolean;
readonly status?: string;
readonly auditPassed?: boolean;
readonly fixedIssues: ReadonlyArray<string>;
readonly auditIssues: ReadonlyArray<ChapterRevisionIssueDetails>;
readonly skippedReason?: string;
}
interface ChapterStateResyncDetails {
readonly chapterNumber?: number;
readonly status?: string;
readonly auditPassed?: boolean;
readonly auditIssues: ReadonlyArray<ChapterRevisionIssueDetails>;
readonly summary?: string;
}
function parseChapterAuditIssues(value: unknown): ReadonlyArray<ChapterRevisionIssueDetails> {
if (!Array.isArray(value)) return [];
return value.flatMap((issue) => {
if (!issue || typeof issue !== "object" || Array.isArray(issue)) return [];
const record = issue as Record<string, unknown>;
const description = stringField(record, "description");
if (!description) return [];
return [{
severity: stringField(record, "severity") ?? "warning",
category: stringField(record, "category") ?? "review",
description,
suggestion: stringField(record, "suggestion"),
}];
});
}
export function getChapterRevisionDetails(exec: ToolExecution): ChapterRevisionDetails | null {
if (exec.tool !== "sub_agent" || !exec.details || typeof exec.details !== "object" || Array.isArray(exec.details)) return null;
const details = exec.details as Record<string, unknown>;
if (details.kind !== "chapter_revision") return null;
return {
chapterNumber: numberField(details, "chapterNumber"),
applied: details.applied === true,
status: stringField(details, "status"),
auditPassed: typeof details.auditPassed === "boolean" ? details.auditPassed : undefined,
fixedIssues: rawStringArrayField(details, "fixedIssues"),
auditIssues: parseChapterAuditIssues(details.auditIssues),
skippedReason: stringField(details, "skippedReason"),
};
}
export function getChapterStateResyncDetails(exec: ToolExecution): ChapterStateResyncDetails | null {
if (exec.tool !== "resync_chapter_state" || !exec.details || typeof exec.details !== "object" || Array.isArray(exec.details)) return null;
const details = exec.details as Record<string, unknown>;
if (details.kind !== "chapter_state_resynced") return null;
return {
chapterNumber: numberField(details, "chapterNumber"),
status: stringField(details, "status"),
auditPassed: typeof details.auditPassed === "boolean" ? details.auditPassed : undefined,
auditIssues: parseChapterAuditIssues(details.auditIssues),
summary: stringField(details, "summary"),
};
}
function ChapterAuditIssues({
issues,
title,
}: {
readonly issues: ReadonlyArray<ChapterRevisionIssueDetails>;
readonly title: string;
}) {
if (issues.length === 0) return null;
return (
<div className="mt-2 space-y-1.5">
<div className="text-[13px] font-medium text-foreground">{title}</div>
{issues.map((issue, index) => (
<div key={`${issue.category}:${index}`} className="rounded-lg border border-border/40 bg-background/55 px-2.5 py-2 text-[12px] leading-5 text-muted-foreground">
<div className="font-medium text-foreground">[{issue.severity}] {issue.category}</div>
<div>{issue.description}</div>
{issue.suggestion && <div className="mt-0.5">{tr("建议", "Suggestion")}{tr("", ": ")}{issue.suggestion}</div>}
</div>
))}
</div>
);
}
function ChapterRevisionPreview({ exec }: { exec: ToolExecution }) {
const details = getChapterRevisionDetails(exec);
if (!details) return null;
const passed = details.applied && details.auditPassed === true;
return (
<div
data-testid="chapter-revision-preview"
className={`mx-3 mb-3 mt-1 rounded-xl border px-3 py-2.5 ${passed ? "border-emerald-500/25 bg-emerald-500/5" : "border-amber-500/25 bg-amber-500/5"}`}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[15px] font-semibold text-foreground">
{details.chapterNumber ? tr(`${details.chapterNumber} 章修订`, `Chapter ${details.chapterNumber} revision`) : tr("章节修订", "Chapter revision")}
</div>
<div className={`rounded-full px-2 py-0.5 text-[12px] font-semibold ${passed ? "bg-emerald-500/15 text-emerald-600" : "bg-amber-500/15 text-amber-600"}`}>
{!details.applied
? tr("保留原稿", "Original kept")
: details.auditPassed
? tr("审稿通过", "Audit passed")
: tr("仍需复核", "Review required")}
</div>
</div>
{details.skippedReason && (
<div className="mt-2 text-[13px] leading-5 text-muted-foreground">{details.skippedReason}</div>
)}
{details.fixedIssues.length > 0 && (
<div className="mt-2 text-[13px] leading-5 text-muted-foreground">
<span className="font-medium text-foreground">{tr("已处理", "Fixed")}{tr("", ": ")}</span>
{details.fixedIssues.join("")}
</div>
)}
<ChapterAuditIssues issues={details.auditIssues} title={tr("剩余审稿问题", "Remaining audit issues")} />
</div>
);
}
function ChapterStateResyncPreview({ exec }: { exec: ToolExecution }) {
const details = getChapterStateResyncDetails(exec);
if (!details) return null;
const passed = details.auditPassed === true;
return (
<div
data-testid="chapter-state-resync-preview"
className={`mx-3 mb-3 mt-1 rounded-xl border px-3 py-2.5 ${passed ? "border-emerald-500/25 bg-emerald-500/5" : "border-amber-500/25 bg-amber-500/5"}`}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[15px] font-semibold text-foreground">
{details.chapterNumber ? tr(`${details.chapterNumber} 章状态已同步`, `Chapter ${details.chapterNumber} state resynced`) : tr("章节状态已同步", "Chapter state resynced")}
</div>
<div className={`rounded-full px-2 py-0.5 text-[12px] font-semibold ${passed ? "bg-emerald-500/15 text-emerald-600" : "bg-amber-500/15 text-amber-600"}`}>
{passed ? tr("审稿通过", "Audit passed") : tr("仍需修订", "Revision required")}
</div>
</div>
{details.summary && <div className="mt-2 text-[13px] leading-5 text-muted-foreground">{details.summary}</div>}
<ChapterAuditIssues issues={details.auditIssues} title={tr("审稿问题", "Audit issues")} />
</div>
);
}
export function getGeneratedArtifactDetails(exec: ToolExecution): GeneratedArtifactDetails | null {
@@ -618,7 +751,6 @@ export function getProposedActionDetails(exec: ToolExecution): ProposedActionDet
execId: exec.id,
action,
targetSessionKind,
targetRoute: proposedTargetRouteField(record),
sameSession: booleanField(record, "sameSession"),
title: stringField(record, "title"),
summary: stringField(record, "summary"),
@@ -682,7 +814,7 @@ function ProposedActionPreview({
{resolution === "confirmed" ? (
<div className="mt-3 flex items-center gap-1.5 text-[15px] leading-6 font-medium text-primary">
<Check size={15} className="shrink-0" />
{details.targetRoute ? tr("已打开", "Opened") : tr("已执行", "Executed")}
{tr("已执行", "Executed")}
</div>
) : resolution === "rejected" ? (
<div className="mt-3 text-[15px] leading-6 font-medium text-muted-foreground">{tr("已取消", "Cancelled")}</div>
@@ -695,7 +827,7 @@ function ProposedActionPreview({
disabled={!onProposedAction || streaming || locked}
className="rounded-lg bg-primary px-3.5 py-2 text-[15px] leading-6 font-medium text-primary-foreground disabled:opacity-50"
>
{streaming ? tr("执行中…", "Running…") : details.targetRoute ? tr("打开入口", "Open entry") : tr("继续执行", "Continue")}
{streaming ? tr("执行中…", "Running…") : tr("继续执行", "Continue")}
</button>
<button
type="button"
@@ -755,8 +887,17 @@ function PlayEditPreview({ exec }: { exec: ToolExecution }) {
);
}
function hasStructuredResultPreview(exec: ToolExecution): boolean {
if (getProposedActionDetails(exec)) return true;
if (getPlayToolDetails(exec)?.sceneText) return true;
if (getChapterRevisionDetails(exec)) return true;
if (getChapterStateResyncDetails(exec)) return true;
return Boolean(getPlayEditDetails(exec));
}
function isPipelineTool(tool: string): boolean {
return tool === "sub_agent"
|| tool === "resync_chapter_state"
|| tool === "context_compression"
|| tool === "propose_action"
|| tool === "short_fiction_run"
@@ -872,12 +1013,14 @@ function PipelineExecution({
<PlayResultPreview exec={exec} />
<PlayEditPreview exec={exec} />
<ChapterContextTracePreview exec={exec} />
<ChapterRevisionPreview exec={exec} />
<ChapterStateResyncPreview exec={exec} />
<NarrativeForecastPreview
exec={exec}
onSelectBranch={onSelectNarrativeBranch}
onRecheck={onRecheckNarrativeForecast}
/>
{!forecastDetails && typeof exec.result === "string" && exec.result.trim() && (
{!forecastDetails && !hasStructuredResultPreview(exec) && typeof exec.result === "string" && exec.result.trim() && (
<PipelineResultDetails result={exec.result} defaultOpen={toolDetailsDefaultOpen} />
)}
<CollapsibleContent>
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import type { ToolExecution } from "../../../store/chat/types";
import { PipelineResultDetails, ToolExecutionSteps, UtilityExecutionRow, buildPlayRunStatusUrl, buildPlaySceneImageUrl, getChapterContextTraceDetails, getExecutionSkillIds, getGeneratedArtifactDetails, getPlayEditDetails, getPlayToolDetails, getProposedActionContractRows, getProposedActionDetails, groupToolExecutionsChronologically } from "../ToolExecutionSteps";
import { PipelineResultDetails, ToolExecutionSteps, UtilityExecutionRow, buildPlayRunStatusUrl, buildPlaySceneImageUrl, getChapterContextTraceDetails, getChapterRevisionDetails, getChapterStateResyncDetails, getExecutionSkillIds, getGeneratedArtifactDetails, getPlayEditDetails, getPlayToolDetails, getProposedActionContractRows, getProposedActionDetails, groupToolExecutionsChronologically } from "../ToolExecutionSteps";
import { usePreferencesStore } from "../../../store/preferences";
import { setAppLanguage } from "../../../lib/app-language";
@@ -179,6 +179,73 @@ describe("groupChronologically", () => {
expect(html).toContain("已完成第 1 章:雨棚");
});
it("renders applied revision audit status and concrete remaining issues", () => {
const exec = makeExec({
id: "revision-1",
tool: "sub_agent",
agent: "reviser",
label: "重写第一章",
result: "Revision complete.",
details: {
kind: "chapter_revision",
chapterNumber: 1,
applied: true,
status: "audit-failed",
auditPassed: false,
fixedIssues: ["统一了孙玉珍和十一分钟的单元案"],
auditIssues: [{
severity: "warning",
category: "continuity",
description: "第一句仍未直接落到孙玉珍抱钟进店。",
suggestion: "把该动作放到首句。",
}],
},
});
expect(getChapterRevisionDetails(exec)).toEqual(expect.objectContaining({
chapterNumber: 1,
applied: true,
auditPassed: false,
}));
const html = renderToStaticMarkup(React.createElement(ToolExecutionSteps, { executions: [exec] }));
expect(html).toContain("第 1 章修订");
expect(html).toContain("仍需复核");
expect(html).toContain("第一句仍未直接落到孙玉珍抱钟进店");
expect(html).not.toContain("查看操作结果");
});
it("renders chapter state resync audit status and concrete issues", () => {
const exec = makeExec({
id: "resync-1",
tool: "resync_chapter_state",
label: "同步章节状态",
result: "State resynced.",
details: {
kind: "chapter_state_resynced",
chapterNumber: 1,
status: "audit-failed",
auditPassed: false,
summary: "状态已重建,正文仍有一个连续性问题。",
auditIssues: [{
severity: "warning",
category: "continuity",
description: "末段还没有体现 H012 的十一分钟证据。",
suggestion: "让末段与已落盘伏笔保持一致。",
}],
},
});
expect(getChapterStateResyncDetails(exec)).toEqual(expect.objectContaining({
chapterNumber: 1,
auditPassed: false,
}));
const html = renderToStaticMarkup(React.createElement(ToolExecutionSteps, { executions: [exec] }));
expect(html).toContain("第 1 章状态已同步");
expect(html).toContain("仍需修订");
expect(html).toContain("末段还没有体现 H012 的十一分钟证据");
expect(html).not.toContain("查看操作结果");
});
it("renders the writer retrieval trace from structured tool details", () => {
const exec = makeExec({
id: "writer-trace",
@@ -464,41 +531,6 @@ describe("groupChronologically", () => {
});
});
it("extracts proposed route actions for existing Studio workflows", () => {
const cases = [
{ action: "fanfic_init", route: "import:fanfic", title: "打开同人创作" },
{ action: "spinoff_create", route: "import:spinoff", title: "打开番外创作" },
{ action: "style_imitation", route: "import:imitation", title: "打开仿写创作" },
] as const;
for (const item of cases) {
const exec = makeExec({
id: `proposal-route-${item.action}`,
tool: "propose_action",
label: "确认动作",
details: {
kind: "proposed_action",
action: item.action,
targetSessionKind: "chat",
targetRoute: item.route,
title: item.title,
summary: "确认后打开对应工具入口。",
instruction: "打开对应工具,等待用户补充材料。",
},
});
expect(getProposedActionDetails(exec)).toMatchObject({
kind: "proposed_action",
execId: `proposal-route-${item.action}`,
action: item.action,
targetSessionKind: "chat",
targetRoute: item.route,
title: item.title,
instruction: "打开对应工具,等待用户补充材料。",
});
}
});
it("extracts Play world and visual contracts for confirmation cards", () => {
const exec = makeExec({
id: "proposal-play-contract",
@@ -534,25 +566,6 @@ describe("groupChronologically", () => {
]);
});
it("ignores invalid proposed target routes", () => {
const exec = makeExec({
id: "proposal-bad-route",
tool: "propose_action",
label: "确认动作",
details: {
kind: "proposed_action",
action: "fanfic_init",
targetSessionKind: "chat",
targetRoute: "https://example.com",
instruction: "打开同人工具。",
},
});
expect(getProposedActionDetails(exec)).toMatchObject({
action: "fanfic_init",
targetRoute: undefined,
});
});
});
describe("tool details default-open preference", () => {
@@ -676,6 +689,26 @@ describe("English app language", () => {
"Visual contract",
]);
});
it("does not repeat raw results when a structured Play preview is available", () => {
const exec = makeExec({
id: "play-start-structured",
tool: "play_start",
label: "启动互动世界",
status: "completed",
result: "雨夜开场正文",
details: {
kind: "play_world_started",
worldId: "rain-world",
runId: "run-1",
sceneText: "雨夜开场正文",
},
});
const html = renderToStaticMarkup(React.createElement(ToolExecutionSteps, { executions: [exec] }));
expect(html).toContain("Interactive world started");
expect(html).not.toContain("View result");
});
});
describe("UtilityExecutionRow", () => {
+3 -94
View File
@@ -1,9 +1,9 @@
import { memo, useRef, useEffect, useMemo, useState } from "react";
import { useRef, useEffect, useMemo, useState } from "react";
import type { Theme } from "../hooks/use-theme";
import type { TFunction } from "../hooks/use-i18n";
import type { SSEMessage } from "../hooks/use-sse";
import { fetchJson, postApi, useApi } from "../hooks/use-api";
import type { ChatAttachmentPayload, MessagePart } from "../store/chat/types";
import type { ChatAttachmentPayload } from "../store/chat/types";
import { chatSelectors, useChatStore } from "../store/chat";
import type { ChatSessionKind } from "../store/chat";
import { useServiceStore } from "../store/service";
@@ -74,8 +74,6 @@ interface Nav {
toDashboard: () => void;
toBook: (id: string) => void;
toServices: () => void;
toImport: (tab?: "chapters" | "canon" | "fanfic" | "spinoff" | "imitation") => void;
toStyle: () => void;
toFilm: (projectId: string) => void;
toFilmStudio: (projectId: string) => void;
}
@@ -177,86 +175,6 @@ function cancelScrollFrame(id: ScrollFrameId): void {
globalThis.clearTimeout(id);
}
type AssistantRenderItem =
| { kind: "thinking"; pi: number; part: Extract<MessagePart, { type: "thinking" }> }
| { kind: "text"; pi: number; part: Extract<MessagePart, { type: "text" }> }
| { kind: "tools"; parts: Array<Extract<MessagePart, { type: "tool" }>>; startIdx: number };
function groupAssistantParts(parts: ReadonlyArray<MessagePart>): AssistantRenderItem[] {
const items: AssistantRenderItem[] = [];
for (let pi = 0; pi < parts.length; pi += 1) {
const part = parts[pi];
if (part.type === "thinking") {
items.push({ kind: "thinking", pi, part });
} else if (part.type === "text") {
items.push({ kind: "text", pi, part });
} else if (part.type === "tool") {
const last = items[items.length - 1];
if (last?.kind === "tools") {
last.parts.push(part);
} else {
items.push({ kind: "tools", parts: [part], startIdx: pi });
}
}
}
return items;
}
const AssistantMessageParts = memo(function AssistantMessageParts({
parts,
timestamp,
theme,
onProposedAction,
onRejectProposedAction,
}: {
readonly parts: ReadonlyArray<MessagePart>;
readonly timestamp: number;
readonly theme: Theme;
readonly onProposedAction?: (details: ProposedActionDetails) => void;
readonly onRejectProposedAction?: (details: ProposedActionDetails) => void;
}) {
const items = useMemo(() => groupAssistantParts(parts), [parts]);
return (
<>
{items.map((item) => {
if (item.kind === "thinking") {
return (
<div key={`t-${item.pi}`} className="mb-2">
<Reasoning isStreaming={item.part.streaming}>
<ReasoningTrigger />
<ReasoningContent>{item.part.content}</ReasoningContent>
</Reasoning>
</div>
);
}
if (item.kind === "tools") {
return (
<ToolExecutionSteps
key={`x-${item.startIdx}`}
executions={item.parts.map((part) => part.execution)}
onProposedAction={onProposedAction}
onRejectProposedAction={onRejectProposedAction}
/>
);
}
if (item.kind === "text" && item.part.content) {
return (
<ChatMessage
key={`c-${item.pi}`}
role="assistant"
content={item.part.content}
timestamp={timestamp}
theme={theme}
/>
);
}
return null;
})}
</>
);
});
function SkillPickerPanel({
isZh,
skills,
@@ -736,15 +654,6 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr
const targetPlayMode = details.targetSessionKind === "play"
? details.actionPayload?.playStart?.mode ?? activeSession?.playMode ?? (details.action === "play_start" ? "open" : undefined)
: undefined;
if (details.targetRoute) {
if (details.targetRoute === "import:fanfic") nav.toImport("fanfic");
else if (details.targetRoute === "import:chapters") nav.toImport("chapters");
else if (details.targetRoute === "import:canon") nav.toImport("canon");
else if (details.targetRoute === "import:spinoff") nav.toImport("spinoff");
else if (details.targetRoute === "import:imitation") nav.toImport("imitation");
else if (details.targetRoute === "style") nav.toStyle();
return;
}
if (details.sameSession && activeSessionId) {
autoScrollPinnedRef.current = true;
await sendMessage(activeSessionId, details.instruction ?? "", {
@@ -961,7 +870,7 @@ export function ChatPage({ activeBookId, mode = activeBookId ? "book" : "book-cr
if (item.kind === "thinking") {
return (
<div key={`t-${item.pi}`} className="mb-2">
<Reasoning isStreaming={item.part.streaming}>
<Reasoning defaultOpen={false} isStreaming={item.part.streaming}>
<ReasoningTrigger />
<ReasoningContent>{item.part.content}</ReasoningContent>
</Reasoning>
@@ -0,0 +1,32 @@
import type { ActionSource, RequestedIntent } from "@actalk/inkos-core";
const CONFIRMED_PRODUCTION_INTENTS: ReadonlySet<RequestedIntent> = new Set([
"create_book",
"write_next",
"short_run",
"play_start",
"generate_cover",
"fanfic_init",
"continuation_import",
"spinoff_create",
"style_imitation",
"script_create",
"storyboard_create",
"interactive_film_create",
"translation_create",
"draft_structure",
"connect_choice",
"remove_node",
]);
/** Free text has no execution authority; only explicit UI/slash actions do. */
export function isConfirmedProductionAction(
actionSource: ActionSource,
requestedIntent: RequestedIntent | undefined,
): boolean {
if (!requestedIntent || !CONFIRMED_PRODUCTION_INTENTS.has(requestedIntent)) return false;
if (requestedIntent === "write_next") {
return actionSource === "button" || actionSource === "slash" || actionSource === "quick-action";
}
return actionSource === "button" || actionSource === "slash";
}
@@ -19,6 +19,10 @@ describe("isConfirmedProductionSend", () => {
it("treats confirmed production intents from button/slash as production sends", () => {
expect(isConfirmedProductionSend("button", "create_book")).toBe(true);
expect(isConfirmedProductionSend("slash", "short_run")).toBe(true);
expect(isConfirmedProductionSend("button", "fanfic_init")).toBe(true);
expect(isConfirmedProductionSend("button", "continuation_import")).toBe(true);
expect(isConfirmedProductionSend("button", "spinoff_create")).toBe(true);
expect(isConfirmedProductionSend("button", "style_imitation")).toBe(true);
});
it("treats quick-action write-next as a production send", () => {
@@ -1,4 +1,5 @@
import type { ChatActionSource, ChatRequestedIntent } from "./types";
import { isConfirmedProductionAction } from "../../shared/confirmed-production";
const READ_ONLY_TOOLS = new Set(["read", "grep", "ls"]);
@@ -6,36 +7,9 @@ export function shouldRefreshSidebarForTool(toolName: string): boolean {
return !READ_ONLY_TOOLS.has(toolName);
}
// 与服务端 server.ts 的确认式生产任务路由保持一致:
// 这些 intent 走服务端的确认式生产分支(task-store 跟踪、可长时间运行)。
// 这样的发送轮不是"聊天轮"——请求会挂起到任务结束,
// 期间用户应当仍能继续聊天,所以它不置 isChatStreaming。
const CONFIRMED_PRODUCTION_INTENTS: ReadonlySet<ChatRequestedIntent> = new Set([
"create_book",
"write_next",
"short_run",
"script_create",
"storyboard_create",
"interactive_film_create",
"translation_create",
"play_start",
"generate_cover",
"draft_structure",
"connect_choice",
"remove_node",
] as const);
export function isConfirmedProductionSend(
actionSource: ChatActionSource,
requestedIntent: ChatRequestedIntent | undefined,
): boolean {
if (requestedIntent === undefined || !CONFIRMED_PRODUCTION_INTENTS.has(requestedIntent)) {
return false;
}
// 写下一章由书籍会话的快捷按钮触发(actionSource=quick-action),
// 服务端同样把它作为后台生产任务执行。
if (requestedIntent === "write_next") {
return actionSource === "button" || actionSource === "slash" || actionSource === "quick-action";
}
return actionSource === "button" || actionSource === "slash";
return isConfirmedProductionAction(actionSource, requestedIntent);
}
@@ -58,6 +58,88 @@ describe("chat message actions", () => {
(globalThis as any).EventSource = originalEventSource;
});
it("aborts only the previous chat round when activating another session", async () => {
const store = createTestStore();
const previousId = store.getState().createDraftSession(null, "chat");
const nextId = store.getState().createDraftSession(null, "chat");
const stream = new FakeEventSource(`/api/v1/events?sessionId=${previousId}`);
store.setState((state) => ({
activeSessionId: previousId,
sessions: {
...state.sessions,
[previousId]: {
...state.sessions[previousId]!,
isStreaming: true,
isChatStreaming: true,
stream: stream as unknown as EventSource,
},
},
}));
fetchJson.mockClear();
store.getState().activateSession(nextId);
expect(store.getState().activeSessionId).toBe(nextId);
expect(store.getState().sessions[previousId]).toMatchObject({
isStreaming: false,
isChatStreaming: false,
stream: null,
});
expect(stream.closed).toBe(true);
await vi.waitFor(() => {
expect(fetchJson).toHaveBeenCalledWith(`/sessions/${previousId}/abort?scope=chat`, { method: "POST" });
});
});
it("keeps a background production task alive when navigation aborts its parallel chat round", async () => {
const store = createTestStore();
const previousId = store.getState().createDraftSession(null, "short");
const nextId = store.getState().createDraftSession(null, "chat");
const stream = new FakeEventSource(`/api/v1/events?sessionId=${previousId}`);
store.setState((state) => ({
activeSessionId: previousId,
sessions: {
...state.sessions,
[previousId]: {
...state.sessions[previousId]!,
isStreaming: true,
isChatStreaming: true,
stream: stream as unknown as EventSource,
messages: [{
role: "assistant",
content: "",
timestamp: 10,
toolExecutions: [{
id: "short-task-1",
tool: "short_fiction_run",
label: "短篇生产",
status: "running",
startedAt: 10,
background: true,
}],
}],
},
},
}));
fetchJson.mockClear();
store.getState().activateSession(nextId);
expect(store.getState().sessions[previousId]).toMatchObject({
isStreaming: true,
isChatStreaming: false,
stream,
});
expect(store.getState().sessions[previousId]?.messages[0]?.toolExecutions?.[0]).toMatchObject({
status: "running",
background: true,
});
expect(stream.closed).toBe(false);
await vi.waitFor(() => {
expect(fetchJson).toHaveBeenCalledWith(`/sessions/${previousId}/abort?scope=chat`, { method: "POST" });
});
});
it("keeps play mode local for draft sessions until the first message persists them", () => {
const store = createTestStore();
const sessionId = store.getState().createDraftSession(null, "play", "open");
@@ -800,6 +882,125 @@ describe("chat message actions", () => {
expect(fakeEventSources[0]?.closed).toBe(true);
});
it("keeps one task card when a replayed snapshot arrives before tool:start", async () => {
const store = createTestStore();
const sessionId = store.getState().createDraftSession(null, "play", "guided");
store.getState().setSelectedModel("deepseek-v4-flash", "kkaiapi");
let resolveAgent!: (value: unknown) => void;
fetchJson
.mockResolvedValueOnce({ session: { sessionId, bookId: null, sessionKind: "play", playMode: "guided" } })
.mockImplementationOnce(() => new Promise((resolve) => {
resolveAgent = resolve;
}));
const sent = store.getState().sendMessage(sessionId, "启动世界", {
sessionKind: "play",
playMode: "guided",
actionSource: "button",
requestedIntent: "play_start",
});
await vi.waitFor(() => expect(fakeEventSources).toHaveLength(1));
const agentRequest = fetchJson.mock.calls.find(([path]) => path === "/agent");
const sourceRequestId = JSON.parse(String(agentRequest?.[1]?.body)).clientRequestId as string;
const execution = {
id: "direct-play_start-1",
tool: "play_start",
label: "启动互动世界",
status: "running" as const,
startedAt: 10,
background: true,
};
fakeEventSources[0]?.emit("task:snapshot", {
sessionId,
sourceRequestId,
execution,
});
fakeEventSources[0]?.emit("tool:start", {
sessionId,
sourceRequestId,
id: execution.id,
tool: execution.tool,
background: true,
});
const matching = (store.getState().sessions[sessionId]?.messages ?? [])
.flatMap((message) => message.toolExecutions ?? [])
.filter((item) => item.id === execution.id);
expect(matching).toHaveLength(1);
fakeEventSources[0]?.emit("tool:end", {
sessionId,
id: execution.id,
tool: execution.tool,
result: "世界已启动",
});
resolveAgent({ response: "", session: { sessionId, sessionKind: "play", playMode: "guided" } });
await sent;
});
it("merges the final HTTP tool result into the existing SSE card by execution id", async () => {
const store = createTestStore();
const sessionId = store.getState().createDraftSession(null, "play", "open");
store.getState().setSelectedModel("deepseek-v4-flash", "kkaiapi");
let resolveAgent!: (value: unknown) => void;
fetchJson
.mockResolvedValueOnce({ session: { sessionId, bookId: null, sessionKind: "play", playMode: "open" } })
.mockImplementationOnce(() => new Promise((resolve) => {
resolveAgent = resolve;
}));
const sent = store.getState().sendMessage(sessionId, "启动开放世界", {
sessionKind: "play",
playMode: "open",
requestedIntent: "play_start",
});
await vi.waitFor(() => expect(fakeEventSources).toHaveLength(1));
const executionId = "direct-play_start-1";
fakeEventSources[0]?.emit("tool:start", {
sessionId,
id: executionId,
tool: "play_start",
background: true,
});
fakeEventSources[0]?.emit("tool:end", {
sessionId,
id: executionId,
tool: "play_start",
details: { kind: "play_world_started", sceneText: "镇口日落。" },
});
resolveAgent({
response: "",
details: {
toolExecutions: [{
id: executionId,
tool: "play_start",
label: "启动互动世界",
status: "completed",
startedAt: 10,
completedAt: 20,
details: { kind: "play_world_started", sceneText: "镇口日落。" },
}],
},
session: { sessionId, sessionKind: "play", playMode: "open" },
});
await sent;
const matching = (store.getState().sessions[sessionId]?.messages ?? [])
.flatMap((message) => message.toolExecutions ?? [])
.filter((execution) => execution.id === executionId);
expect(matching).toHaveLength(1);
expect(matching[0]).toMatchObject({
status: "completed",
completedAt: 20,
details: { kind: "play_world_started", sceneText: "镇口日落。" },
});
});
it("reclassifies a free-text turn from a replayed task snapshot and stops the production task", async () => {
const store = createTestStore();
const sessionId = store.getState().createDraftSession("demo-book", "book");
@@ -22,6 +22,7 @@ import {
extractErrorMessage,
hasAnyInFlightExecution,
markRunningToolsFailed,
mergeToolExecution,
mergeTaskExecution,
mergeSessionIds,
updateSession,
@@ -76,9 +77,19 @@ function formatUserMessageForDisplay(text: string, attachments: ReadonlyArray<Ch
return lines.join("\n");
}
export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions> = (set, get) => ({
activateSession: (sessionId) =>
set({ activeSessionId: sessionId }),
export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions> = (set, get) => {
const abortPreviousChatRound = (nextSessionId: string | null): void => {
const previousSessionId = get().activeSessionId;
if (!previousSessionId || previousSessionId === nextSessionId) return;
if (!get().sessions[previousSessionId]?.isChatStreaming) return;
void get().abortSession(previousSessionId, "chat");
};
return {
activateSession: (sessionId) => {
abortPreviousChatRound(sessionId);
set({ activeSessionId: sessionId });
},
setSessionPlayMode: (sessionId, playMode) => {
const session = get().sessions[sessionId];
@@ -223,6 +234,7 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
},
createSession: async (bookId, sessionKind, playMode) => {
abortPreviousChatRound(null);
const data = await fetchJson<SessionResponse>("/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -261,6 +273,7 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
},
createDraftSession: (bookId, sessionKind, playMode) => {
abortPreviousChatRound(null);
// 前端生成 sessionId(与后端 createBookSession 同格式),暂不持久化到磁盘,
// 也暂不写入 sessionIdsByBook——侧边栏看不到这条 draft。
// 发送第一条消息时 sendMessage 会调 POST /sessions { sessionId, bookId } 落盘
@@ -340,22 +353,30 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
});
},
abortSession: async (sessionId) => {
abortSession: async (sessionId, scope = "all") => {
const session = get().sessions[sessionId];
session?.stream?.close();
const stoppedAt = Date.now();
const stoppedMessage = tr("已由用户停止", "Stopped by user");
const chatOnly = scope === "chat";
const messages = markRunningToolsFailed(
session?.messages ?? [],
stoppedMessage,
stoppedAt,
(execution) => !chatOnly || execution.background !== true,
);
const keepProductionStream = chatOnly && hasAnyInFlightExecution(messages);
if (!keepProductionStream) session?.stream?.close();
set((state) => ({
sessions: updateSession(state.sessions, sessionId, (runtime) => ({
isStreaming: false,
isStreaming: keepProductionStream,
isChatStreaming: false,
stream: null,
stream: keepProductionStream ? runtime.stream : null,
lastError: null,
messages: markRunningToolsFailed(runtime.messages, stoppedMessage, stoppedAt),
messages,
})),
}));
try {
await fetchJson(`/sessions/${sessionId}/abort`, {
await fetchJson(`/sessions/${sessionId}/abort${chatOnly ? "?scope=chat" : ""}`, {
method: "POST",
});
} catch (error) {
@@ -591,11 +612,10 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
if (responseToolExecutions.length === 0) return;
set((state) => ({
sessions: updateSession(state.sessions, sessionId, (runtime) => ({
messages: runtime.messages.map((message) => (
message.timestamp === streamTs && message.role === "assistant"
? withToolExecutions(message, responseToolExecutions)
: message
)),
messages: responseToolExecutions.reduce<ReadonlyArray<(typeof runtime.messages)[number]>>(
(messages, execution) => mergeToolExecution(messages, execution),
runtime.messages,
),
})),
}));
};
@@ -716,5 +736,6 @@ export const createMessageSlice: StateCreator<ChatStore, [], [], MessageActions>
sessions: updateSession(state.sessions, sessionId, () => ({ lastFailedSend: undefined })),
}));
await get().sendMessage(sessionId, failed.text, failed.options);
},
});
},
};
};
@@ -232,15 +232,10 @@ export function deserializeMessages(
});
}
export function mergeTaskExecution(
export function mergeToolExecution(
messages: ReadonlyArray<Message>,
taskExecution: ToolExecution,
execution: ToolExecution,
): ReadonlyArray<Message> {
// 任务快照必然来自后台生产任务:恢复出的卡带 background 标记,供无 id
// 事件的回退路由跳过它。终态快照替换整个 execution,标记也要跟着补回来。
const execution: ToolExecution = taskExecution.background
? taskExecution
: { ...taskExecution, background: true };
let found = false;
const next = messages.map((message) => {
const hasDirectExecution = message.toolExecutions?.some((item) => item.id === execution.id) ?? false;
@@ -276,6 +271,18 @@ export function mergeTaskExecution(
];
}
export function mergeTaskExecution(
messages: ReadonlyArray<Message>,
taskExecution: ToolExecution,
): ReadonlyArray<Message> {
// 任务快照必然来自后台生产任务:恢复出的卡带 background 标记,供无 id
// 事件的回退路由跳过它。终态快照替换整个 execution,标记也要跟着补回来。
const execution: ToolExecution = taskExecution.background
? taskExecution
: { ...taskExecution, background: true };
return mergeToolExecution(messages, execution);
}
export function hasInFlightExecution(
messages: ReadonlyArray<Message>,
executionId: string,
@@ -335,9 +342,10 @@ export function markRunningToolsFailed(
messages: ReadonlyArray<Message>,
error: string,
completedAt = Date.now(),
shouldFail: (execution: ToolExecution) => boolean = () => true,
): ReadonlyArray<Message> {
const failExecution = (execution: ToolExecution): ToolExecution => (
execution.status === "running" || execution.status === "processing"
(execution.status === "running" || execution.status === "processing") && shouldFail(execution)
? { ...execution, status: "error", error, completedAt }
: execution
);
@@ -467,6 +467,16 @@ export function attachSessionStreamListeners({
flushTextDeltas();
set((state) => ({
sessions: updateSession(state.sessions, sessionId, (runtime) => {
const executionId = data.id as string;
const alreadyTracked = runtime.messages.some((message) => (
message.toolExecutions?.some((execution) => execution.id === executionId)
|| message.parts?.some((part) => part.type === "tool" && part.execution.id === executionId)
));
if (alreadyTracked) {
return background && belongsToCurrentRequest && runtime.isChatStreaming
? { isChatStreaming: false }
: {};
}
const [messages, stream] = getOrCreateStream(runtime.messages, streamTs);
const parts = [...(stream.parts ?? [])];
@@ -494,7 +504,7 @@ export function attachSessionStreamListeners({
parts.push({
type: "tool",
execution: {
id: data.id as string,
id: executionId,
tool: data.tool as string,
agent,
label: resolveToolLabel(data.tool as string, agent),
+3 -2
View File
@@ -225,8 +225,9 @@ export interface MessageActions {
sendMessage: (sessionId: string, text: string, options?: SendMessageOptions) => Promise<void>;
// 用 lastFailedSend 记录的原样参数重发上一条失败的消息;无记录或聊天轮流式中时不做任何事。
retryLastSend: (sessionId: string) => Promise<void>;
// A stop aborts the Pi turn and its complete serial production workflow.
abortSession: (sessionId: string) => Promise<void>;
// User stop aborts the complete workflow; navigation uses chat scope so a
// background production task can keep running after its Pi turn is cancelled.
abortSession: (sessionId: string, scope?: "all" | "chat") => Promise<void>;
setSelectedModel: (model: string, service: string) => void;
}