diff --git a/packages/core/skills/inkos-long-writing/SKILL.md b/packages/core/skills/inkos-long-writing/SKILL.md index 73cbb1f5..df9d595a 100644 --- a/packages/core/skills/inkos-long-writing/SKILL.md +++ b/packages/core/skills/inkos-long-writing/SKILL.md @@ -1,17 +1,17 @@ --- name: inkos-long-writing -description: 长篇小说建书、续写、方向调整与章节协作。Use for long-form creation while preserving author intent, canon, and review gates. +description: 长篇小说的场景构造、人物因果、信息释放与连载节奏。Used by InkOS long-form workers as their shared craft method. --- -# Long-form writing +# Long-form narrative craft -Use this skill for creating, planning, continuing, or substantially revising a long-form book. +Apply this method inside the active architect, writer, auditor, or reviser operation. -- Conversation comes first. A discussion, question, or request for options is not permission to write or edit. -- In general chat, propose the appropriate action and wait for confirmation. In an active book, use the existing `sub_agent` writer, auditor, or reviser rather than drafting chapter prose in chat. -- Preserve the authority order: established facts and published chapters; durable book rules; current author direction; model defaults and genre convention. -- When sources are bound to the book, use them only for the purposes the user stated. Never let a reference override author intent or canon, and never reproduce source wording. -- Make each scene change conflict, evidence, emotion, relationship, knowledge, or consequence. Do not pad to satisfy length. -- Report the real tool result and review status. Never claim a chapter was saved without a successful artifact result. -- Respond in the user's language. - -Load `references/collaboration-protocol.md` when deciding between discussion, writing, revision, and canon change. +- Turn the chapter goal into scenes with an immediate objective, resistance, a meaningful turn, and consequences that survive the scene. +- Let character choices follow what each person wants, knows, fears, and can afford. Relationships change through events, not author explanation. +- Reveal setting and backstory through action, evidence, dialogue, and sensory particulars. Do not replace a scene with a synopsis or analysis. +- Every passage must alter conflict, evidence, emotion, relationship, knowledge, position, or future consequence. Remove padding rather than stretching to a number. +- Control information deliberately: answer some active reader questions, deepen others through concrete evidence, and do not manufacture twists by hiding facts the viewpoint should know. +- Preserve the user's voice, viewpoint, prohibitions, proportions, and current direction. Genre convention is only a default when the governed context is silent. +- End a chapter after a material change or fresh pressure, not with a mechanical cliffhanger formula. Show the after-effect of major payoffs before starting another escalation cycle. +- Use references as evidence for craft or facts only. Do not reproduce source wording, names, scene order, or signature combinations. +- During review or revision, diagnose and repair the smallest layer that actually causes the problem. A formatting failure is not evidence that the prose is bad. diff --git a/packages/core/skills/inkos-long-writing/references/collaboration-protocol.md b/packages/core/skills/inkos-long-writing/references/collaboration-protocol.md deleted file mode 100644 index 8f4b6399..00000000 --- a/packages/core/skills/inkos-long-writing/references/collaboration-protocol.md +++ /dev/null @@ -1,15 +0,0 @@ -# Long-form collaboration protocol - -## Decide the operation semantically - -- Explore or compare directions: answer in conversation. -- Create a new book: discuss the core, then propose confirmation. -- Write the next chapter or several sequential chapters: use the writer pipeline after explicit execution intent. -- Diagnose an existing chapter: use the auditor and show concrete findings. -- Rewrite or restructure an existing chapter: use the reviser with the user's instruction and appropriate revision mode. -- Change durable setting: explain conflicts, then use the truth-editing path after explicit authorization. -- Retcon published history: stop and present the conflict and affected scope before editing. - -## Completion - -Completion comes from persisted artifacts and tool results, not assistant prose. Preserve failed review details and let the user choose whether to revise, accept, or change the standard. diff --git a/packages/core/src/__tests__/agent-session.test.ts b/packages/core/src/__tests__/agent-session.test.ts index 78a4063a..20968d0e 100644 --- a/packages/core/src/__tests__/agent-session.test.ts +++ b/packages/core/src/__tests__/agent-session.test.ts @@ -990,7 +990,7 @@ describe("runAgentSession cache — bookId switch", () => { ); }); - it("blocks raw chapter prose in book chat when no production tool ran", async () => { + it("does not rewrite assistant prose by guessing whether it looks like a chapter", async () => { const model = { provider: "x", id: "y", api: "anthropic-messages" } as any; const pipeline = {} as any; @@ -999,9 +999,8 @@ describe("runAgentSession cache — bookId switch", () => { "raw chapter", ); - expect(result.responseText).toContain("没有调用落盘工具"); - expect(result.responseText).toContain("修改旧章"); - expect(result.responseText).not.toContain("# 第2章"); + expect(result.responseText).toContain("# 第2章"); + expect(result.responseText).not.toContain("没有调用落盘工具"); }); it("does not replace chapter-scoped revision instructions as raw chapter prose", async () => { diff --git a/packages/core/src/__tests__/agent-system-prompt.test.ts b/packages/core/src/__tests__/agent-system-prompt.test.ts index e74436cf..3b6ca2c0 100644 --- a/packages/core/src/__tests__/agent-system-prompt.test.ts +++ b/packages/core/src/__tests__/agent-system-prompt.test.ts @@ -21,8 +21,7 @@ describe("buildAgentSystemPrompt", () => { expect(prompt).toContain("当前正在处理书籍「my-book」"); expect(prompt).toContain("sub_agent"); expect(prompt).toContain("writer"); - expect(prompt).toContain("角色卡编辑走 write_truth_file"); - expect(prompt).toContain("roles/主要角色/<角色名>.md"); + expect(prompt).toContain("修改设定或角色卡时先读取权威文件"); }); it("English plain chat also has no production tool instructions", () => { @@ -424,26 +423,20 @@ describe("buildAgentSystemPrompt", () => { }); describe("book mode", () => { - it("contains active-book writing tools and no cross-mode production tools", () => { + it("keeps structural action boundaries without duplicating tool schemas", () => { const prompt = buildAgentSystemPrompt("my-book", "zh", "book"); expect(prompt).toContain("my-book"); expect(prompt).toContain("sub_agent"); expect(prompt).toContain("writer"); expect(prompt).toContain("auditor"); expect(prompt).toContain("reviser"); - expect(prompt).toContain("chapterWordCount"); - expect(prompt).toContain("chapterNumber"); - expect(prompt).toContain("anti-detect"); - expect(prompt).toContain("approvedOnly"); - expect(prompt).toContain("generate_cover"); - expect(prompt).toContain("read"); - expect(prompt).toContain("write_truth_file"); - expect(prompt).toContain("rename_entity"); - expect(prompt).toContain("patch_chapter_text"); - expect(prompt).toContain("grep"); - expect(prompt).toContain("ls"); - expect(prompt).toContain("outline/story_frame.md"); - expect(prompt).toContain("roles/major/.md"); + expect(prompt).toContain("工具 schema 是参数与能力的唯一说明"); + expect(prompt).toContain("不要把讨论猜成执行命令"); + expect(prompt).toContain("研究报告、资料卡和检索片段只是参考"); + expect(prompt).not.toContain("## 可用工具"); + expect(prompt).not.toContain("chapterWordCount"); + expect(prompt).not.toContain("approvedOnly"); + expect(prompt).not.toContain("roles/major/.md"); expect(prompt).not.toContain("short_fiction_run"); expect(prompt).not.toContain("play_start"); expect(prompt).not.toContain("play_step"); @@ -452,25 +445,23 @@ describe("buildAgentSystemPrompt", () => { it("steers chapter rewrite to reviser instead of writer", () => { const prompt = buildAgentSystemPrompt("my-book", "zh", "book"); - expect(prompt).toContain("改 / 修订 / 重写第 N 章"); - expect(prompt).toContain("sub_agent(agent=\"reviser\", chapterNumber=N)"); - expect(prompt).toContain("writer 只会续写新的下一章"); - expect(prompt).toContain("不要用 writer"); + expect(prompt).toContain("续写新的下一章用 writer"); + expect(prompt).toContain("修改、重写或重修已有章节用 reviser"); + expect(prompt).toContain("三者不可互换"); }); it("forbids answering chapter-writing requests with raw chapter prose in chat", () => { const prompt = buildAgentSystemPrompt("my-book", "zh", "book"); - expect(prompt).toContain("不要在聊天回答里直接写章节正文"); - expect(prompt).toContain("不能输出“# 第 N 章”"); - expect(prompt).toContain("必须调用 sub_agent(agent=\"writer\")"); - expect(prompt).toContain("sub_agent 成功返回后,本轮直接结束"); + expect(prompt).toContain("不要在聊天正文里输出章节来冒充完成"); + expect(prompt).toContain("sub_agent 成功后结束本轮"); + expect(prompt).toContain("完成态只以成功工具结果为准"); }); it("English active-book prompt is also isolated", () => { const prompt = buildAgentSystemPrompt("novel", "en", "book"); expect(prompt).toContain("working on book \"novel\""); expect(prompt).toContain("sub_agent"); - expect(prompt).toContain("generate_cover"); + expect(prompt).toContain("Tool schemas are the sole contract"); expect(prompt).not.toContain("short_fiction_run"); expect(prompt).not.toContain("play_start"); expect(prompt).not.toMatch(/agent="architect"/); diff --git a/packages/core/src/__tests__/agent-tools-params.test.ts b/packages/core/src/__tests__/agent-tools-params.test.ts index 0ea8f410..b74e00aa 100644 --- a/packages/core/src/__tests__/agent-tools-params.test.ts +++ b/packages/core/src/__tests__/agent-tools-params.test.ts @@ -154,12 +154,12 @@ describe("writer agent — wordCount passthrough", () => { it("passes chapterWordCount as wordCount", async () => { await tool.execute("tc1", { agent: "writer", instruction: "Write", bookId: "my-book", chapterWordCount: 5000 }); - expect(writeNextChapterMock).toHaveBeenCalledWith("my-book", 5000); + expect(writeNextChapterMock).toHaveBeenCalledWith("my-book", 5000, undefined, "Write"); }); it("passes undefined when chapterWordCount omitted", async () => { await tool.execute("tc2", { agent: "writer", instruction: "Write", bookId: "my-book" }); - expect(writeNextChapterMock).toHaveBeenCalledWith("my-book", undefined); + expect(writeNextChapterMock).toHaveBeenCalledWith("my-book", undefined, undefined, "Write"); }); }); diff --git a/packages/core/src/__tests__/agent-tools.test.ts b/packages/core/src/__tests__/agent-tools.test.ts index eb4c240e..3dff8588 100644 --- a/packages/core/src/__tests__/agent-tools.test.ts +++ b/packages/core/src/__tests__/agent-tools.test.ts @@ -353,10 +353,10 @@ describe("agent deterministic writing tools", () => { }); }); - it("rejects truncated play initial scenes in confirmation payloads", async () => { + it("preserves the model-proposed Play scene without semantic template filtering", async () => { const tool = createProposeActionTool("zh"); - await expect(tool.execute("proposal-play", { + const result = await tool.execute("proposal-play", { action: "play_start", instruction: "开一个旧戏院检修互动世界,从配电室和后台开始。", playStart: { @@ -366,7 +366,15 @@ describe("agent deterministic writing tools", () => { initialScene: "剧目是《挑滑车》,主演栏里有个名字叫", suggestedActions: ["检查演出表", "走向配电室"], }, - })).rejects.toThrow("playStart.initialScene"); + }); + + expect(result.details).toMatchObject({ + actionPayload: { + playStart: { + initialScene: "剧目是《挑滑车》,主演栏里有个名字叫", + }, + }, + }); }); it("keeps play world and visual contracts in the structured confirmation payload", async () => { @@ -513,7 +521,7 @@ describe("agent deterministic writing tools", () => { expect(JSON.stringify(result.details)).not.toContain("episodeCount"); }); - it("falls back to the tool argument when confirmed play payload contains a truncated initial scene", async () => { + it("uses the confirmed Play scene as the execution source of truth", async () => { let seededScene = ""; const pipeline = { createAgentContext: vi.fn(() => ({})), @@ -544,10 +552,9 @@ describe("agent deterministic writing tools", () => { suggestedActions: ["检查演出表"], }); - expect(seededScene).toContain("主演栏写着赵铁生"); - expect(seededScene).not.toContain("名字叫"); + expect(seededScene).toContain("主演栏里有个名字叫"); await expect(readFile(join(root, "worlds", "play-session-truncated", "runs", "main", "projections", "scene.md"), "utf-8")) - .resolves.toContain("主演栏写着赵铁生"); + .resolves.toContain("主演栏里有个名字叫"); }); it("does not emit a confirmation card when the proposed action payload is invalid", async () => { @@ -749,7 +756,12 @@ describe("agent deterministic writing tools", () => { instruction: "继续写,控制在 2600 字", } as any); - expect(pipeline.writeNextChapter).toHaveBeenCalledWith("harbor", 2600); + expect(pipeline.writeNextChapter).toHaveBeenCalledWith( + "harbor", + 2600, + undefined, + "继续写,控制在 2600 字", + ); }); it("runs a requested chapter batch through one writer operation", async () => { @@ -803,6 +815,82 @@ describe("agent deterministic writing tools", () => { expect(pipeline.writeNextChapter).toHaveBeenCalledOnce(); }); + it("passes activated Skill guidance and the exact user instruction into the writer", async () => { + const activatedSkills = [{ + skill: { + id: "longform-pacing", + name: "Long-form pacing", + description: "Keep scene-level cause and effect visible.", + body: "Every turn must alter pressure, evidence, or relationship state.", + source: "external" as const, + }, + resources: [{ + path: "references/pacing.md", + heading: "Pressure chain", + body: "Escalate through consequences rather than arbitrary surprises.", + charStart: 12, + charEnd: 88, + }], + }]; + const pipeline = { + runWithAgentContext: vi.fn(async (_context: unknown, task: () => Promise) => task()), + writeNextChapter: vi.fn(async () => ({ chapterNumber: 4, wordCount: 2600 })), + }; + const instruction = "重写节奏方向:这一章先让证据链反噬主角,不要直接揭晓凶手。"; + const tool = createSubAgentTool(pipeline as never, "harbor", undefined, { + activeSkills: () => activatedSkills, + }); + + const result = await tool.execute("tool-writer-skill", { + agent: "writer", + instruction, + } as any); + + expect(pipeline.runWithAgentContext).toHaveBeenCalledWith( + { signal: undefined, activatedSkills }, + expect.any(Function), + ); + expect(pipeline.writeNextChapter).toHaveBeenCalledWith("harbor", undefined, undefined, instruction); + expect(result.details).toMatchObject({ + kind: "chapter_written", + skillIds: ["longform-pacing"], + }); + }); + + it("injects the host-selected long-writing Skill into the worker without relying on agent intent", async () => { + const longWritingSkill = { + skill: { + id: "inkos-long-writing", + name: "Long-form narrative craft", + description: "Shared long-form worker method.", + body: "Build scenes through objective, resistance, turn, and consequence.", + source: "builtin" as const, + }, + resources: [], + }; + const pipeline = { + runWithAgentContext: vi.fn(async (_context: unknown, task: () => Promise) => task()), + writeNextChapter: vi.fn(async () => ({ chapterNumber: 2, wordCount: 2400 })), + }; + const tool = createSubAgentTool(pipeline as never, "harbor", undefined, { + workerSkills: (agent) => agent === "writer" ? [longWritingSkill] : [], + }); + + const result = await tool.execute("tool-writer-default-skill", { + agent: "writer", + instruction: "让这一章用一场谈判改变两人的关系。", + } as any); + + expect(pipeline.runWithAgentContext).toHaveBeenCalledWith( + { signal: undefined, activatedSkills: [longWritingSkill] }, + expect.any(Function), + ); + expect(result.details).toMatchObject({ + kind: "chapter_written", + skillIds: ["inkos-long-writing"], + }); + }); + it("does not claim writer success when the chapter audit failed", async () => { const pipeline = { writeNextChapter: vi.fn(async () => ({ @@ -916,7 +1004,27 @@ describe("agent deterministic writing tools", () => { instruction: "继续写下一章", } as any); - expect(pipeline.writeNextChapter).toHaveBeenCalledWith("harbor", 2600); + expect(pipeline.writeNextChapter).toHaveBeenCalledWith( + "harbor", + 2600, + undefined, + "继续写下一章", + ); + }); + + it("uses structured exporter arguments instead of parsing natural-language instruction", async () => { + const tool = createSubAgentTool({} as never, "harbor", root); + + const result = await tool.execute("tool-export-defaults", { + agent: "exporter", + instruction: "请导出 EPUB,并且只要已通过章节", + } as any); + + expect(result.content[0]?.type).toBe("text"); + if (result.content[0]?.type === "text") { + expect(result.content[0].text).toContain(".txt"); + expect(result.content[0].text).not.toContain(".epub"); + } }); it("documents sub_agent bookId as an optional active-book override", () => { diff --git a/packages/core/src/__tests__/external-skill-loader.test.ts b/packages/core/src/__tests__/external-skill-loader.test.ts index 47dd0a70..05bf2aab 100644 --- a/packages/core/src/__tests__/external-skill-loader.test.ts +++ b/packages/core/src/__tests__/external-skill-loader.test.ts @@ -44,7 +44,7 @@ describe("external skill loader", () => { expect.objectContaining({ id: "inkos-long-writing", source: "builtin", - body: expect.stringContaining("established facts"), + body: expect.stringContaining("objective, resistance"), baseDir: expect.stringMatching(/skills[\\/]inkos-long-writing$/), }), expect.objectContaining({ diff --git a/packages/core/src/__tests__/interaction-models.test.ts b/packages/core/src/__tests__/interaction-models.test.ts index 859f232e..74a826b8 100644 --- a/packages/core/src/__tests__/interaction-models.test.ts +++ b/packages/core/src/__tests__/interaction-models.test.ts @@ -17,9 +17,6 @@ import { bindActiveBook, clearPendingDecision, isTerminalExecutionStatus, - isExplicitWriteChapterCommand, - isUsablePlayInitialScene, - isWriteNextInstruction, normalizeActionSource, normalizePlayMode, normalizeRequestedIntent, @@ -116,32 +113,6 @@ describe("interaction models", () => { }); }); - it("uses one write-next detector across Studio and TUI entrypoints", () => { - expect(isWriteNextInstruction("继续写")).toBe(true); - expect(isWriteNextInstruction("write next")).toBe(true); - expect(isWriteNextInstruction("/write")).toBe(false); - expect(isWriteNextInstruction("/write", { allowSlashWrite: true })).toBe(true); - expect(isWriteNextInstruction("我们讨论一下要不要继续写")).toBe(false); - }); - - it("recognizes only explicit natural-language chapter writing commands", () => { - expect(isExplicitWriteChapterCommand("开始写第一章。")).toBe(true); - expect(isExplicitWriteChapterCommand("请写下一章,写完后落盘。")).toBe(true); - expect(isExplicitWriteChapterCommand("write chapter 1")).toBe(true); - expect(isExplicitWriteChapterCommand("连续写5章")).toBe(false); - expect(isExplicitWriteChapterCommand("write 5 chapters")).toBe(false); - expect(isExplicitWriteChapterCommand("写第5章")).toBe(false); - expect(isExplicitWriteChapterCommand("继续")).toBe(false); - expect(isExplicitWriteChapterCommand("我们讨论一下要不要写下一章")).toBe(false); - expect(isExplicitWriteChapterCommand("我觉得第一章应该怎么写?")).toBe(false); - }); - - it("rejects obviously truncated play initial scenes before they become execution payloads", () => { - expect(isUsablePlayInitialScene("暴雨敲着铁皮门,封存档案箱压在门口。")).toBe(true); - expect(isUsablePlayInitialScene("剧目是《挑滑车》,主演栏里有个名字叫")).toBe(false); - expect(isUsablePlayInitialScene("主演栏:赵铁生。后台传来第二声拍板。")).toBe(true); - }); - it("recognizes terminal execution statuses", () => { expect(isTerminalExecutionStatus(ExecutionStatusSchema.parse("completed"))).toBe(true); expect(isTerminalExecutionStatus(ExecutionStatusSchema.parse("failed"))).toBe(true); diff --git a/packages/core/src/__tests__/local-search.test.ts b/packages/core/src/__tests__/local-search.test.ts new file mode 100644 index 00000000..5f1a41eb --- /dev/null +++ b/packages/core/src/__tests__/local-search.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { + LocalSearchIndex, + splitMarkdownForSearch, + tokenizeSearchText, +} from "../retrieval/local-search.js"; + +describe("LocalSearchIndex", () => { + it("retrieves Chinese and English evidence with FTS5 BM25", () => { + const index = new LocalSearchIndex(":memory:"); + try { + index.replaceScope("story", [ + { + id: "mentor-debt", + scope: "story", + kind: "hook", + source: "pending_hooks.md#mentor-debt", + title: "师债回响 Mentor Debt", + body: "林月必须核对誓令碎片,继续追查失踪导师留下的债务。", + }, + { + id: "guild-route", + scope: "story", + kind: "hook", + source: "pending_hooks.md#guild-route", + title: "商会路线 Guild Route", + body: "商会提出一条安全但偏离主线的运输路线。", + }, + ]); + + expect(index.search("导师 誓令 师债", { scope: "story" })[0]?.id).toBe("mentor-debt"); + expect(index.search("mentor debt oath", { scope: "story" })[0]?.id).toBe("mentor-debt"); + } finally { + index.close(); + } + }); + + it("replaces stale projection rows instead of retaining old evidence", () => { + const index = new LocalSearchIndex(":memory:"); + try { + index.replaceScope("materials", [{ + id: "old", + scope: "materials", + kind: "reference", + source: "old.md", + title: "旧账", + body: "0607 赔偿款", + }]); + index.replaceScope("materials", [{ + id: "new", + scope: "materials", + kind: "reference", + source: "new.md", + title: "新账", + body: "0812 签收单", + }]); + + expect(index.search("赔偿款", { scope: "materials" })).toEqual([]); + expect(index.search("签收单", { scope: "materials" })[0]?.id).toBe("new"); + } finally { + index.close(); + } + }); + + it("keeps identical document ids isolated across scopes", () => { + const index = new LocalSearchIndex(":memory:"); + try { + index.replaceScope("story", [{ + id: "shared", + scope: "story", + kind: "hook", + source: "pending_hooks.md#shared", + title: "失踪导师", + body: "导师留下誓令碎片。", + }]); + index.replaceScope("materials", [{ + id: "shared", + scope: "materials", + kind: "reference", + source: "sample.md#shared", + title: "赔偿账页", + body: "账页记录三笔赔偿款。", + }]); + + expect(index.search("导师", { scope: "story" })[0]?.source).toBe("pending_hooks.md#shared"); + expect(index.search("赔偿", { scope: "materials" })[0]?.source).toBe("sample.md#shared"); + } finally { + index.close(); + } + }); + + it("segments Markdown without truncating the selected paragraphs", () => { + const markdown = "# 证据\n\n赔偿款被拆成三笔。\n\n## 人物\n\n司机拒绝签字。"; + const segments = splitMarkdownForSearch(markdown); + + expect(segments.map((segment) => segment.body)).toEqual([ + "赔偿款被拆成三笔。", + "司机拒绝签字。", + ]); + expect(markdown.slice(segments[1]!.charStart, segments[1]!.charEnd)).toBe("司机拒绝签字。"); + expect(tokenizeSearchText("师债 mentor-debt")).toEqual(expect.arrayContaining(["师债", "mentor-debt"])); + }); +}); diff --git a/packages/core/src/__tests__/memory-retrieval.test.ts b/packages/core/src/__tests__/memory-retrieval.test.ts index f35ebca7..69536f9b 100644 --- a/packages/core/src/__tests__/memory-retrieval.test.ts +++ b/packages/core/src/__tests__/memory-retrieval.test.ts @@ -1,23 +1,13 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { createRequire } from "node:module"; +import { afterEach, describe, expect, it } from "vitest"; import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import * as memoryRetrieval from "../utils/memory-retrieval.js"; -import { retrieveMemorySelection } from "../utils/memory-retrieval.js"; +import { + parsePendingHooksMarkdown, + retrieveMemorySelection, +} from "../utils/memory-retrieval.js"; import { MemoryDB } from "../state/memory-db.js"; -const require = createRequire(import.meta.url); -const hasNodeSqlite = (() => { - try { - require("node:sqlite"); - return true; - } catch { - return false; - } -})(); -const sqliteIt = hasNodeSqlite ? it : it.skip; - describe("retrieveMemorySelection", () => { let root = ""; @@ -26,8 +16,6 @@ describe("retrieveMemorySelection", () => { await rm(root, { recursive: true, force: true }); root = ""; } - vi.resetModules(); - vi.doUnmock("../state/memory-db.js"); }); it("indexes current state facts into sqlite-backed memory selection", async () => { @@ -75,11 +63,8 @@ describe("retrieveMemorySelection", () => { }), ]), ); - if (hasNodeSqlite) { - expect(result.dbPath).toContain("memory.db"); - } else { - expect(result.dbPath).toBeUndefined(); - } + expect(result.dbPath).toContain("memory.db"); + expect(result.retrievalTrace.engine).toBe("sqlite-fts5-bm25"); }); it("does not treat unpromoted hook seeds as active debt", async () => { @@ -116,41 +101,6 @@ describe("retrieveMemorySelection", () => { expect(result.hooks.map((hook) => hook.hookId)).not.toContain("H-seed"); }); - it("extracts mentor-focused query terms without pulling guild-route negatives into English retrieval", () => { - const extractQueryTerms = (memoryRetrieval as Record).extractQueryTerms as - | ((goal: string, outlineNode: string | undefined, mustKeep: ReadonlyArray) => ReadonlyArray) - | undefined; - - expect(extractQueryTerms).toBeDefined(); - const terms = extractQueryTerms?.( - "Pull focus back to the mentor debt and do not open a new frontier in this chapter.", - "Handle guild noise without letting the guild route overtake the mentor-debt mainline.", - ["Lin Yue does not abandon the mentor debt."], - ) ?? []; - - expect(terms).toContain("mentor"); - expect(terms).toContain("debt"); - expect(terms).not.toContain("guild"); - expect(terms).not.toContain("route"); - }); - - it("extracts 师债-focused query terms without pulling 商会路线 negatives into Chinese retrieval", () => { - const extractQueryTerms = (memoryRetrieval as Record).extractQueryTerms as - | ((goal: string, outlineNode: string | undefined, mustKeep: ReadonlyArray) => ReadonlyArray) - | undefined; - - expect(extractQueryTerms).toBeDefined(); - const terms = extractQueryTerms?.( - "第51章把注意力拉回师债,不让商会路线盖过主线。", - "处理商会噪音,但不允许商会路线盖过师债主线。", - ["林月不会放弃师债。"], - ) ?? []; - - expect(terms).toContain("师债"); - expect(terms).not.toContain("商会"); - expect(terms).not.toContain("商会路线"); - }); - 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"); @@ -265,200 +215,7 @@ describe("retrieveMemorySelection", () => { expect(result.summaries.map((summary) => summary.chapter)).toContain(50); }); - it("keeps the mentor-debt recap chapter in markdown fallback mode for English books", async () => { - root = await mkdtemp(join(tmpdir(), "inkos-memory-retrieval-en-fallback-test-")); - const bookDir = join(root, "book"); - const storyDir = join(bookDir, "story"); - await mkdir(storyDir, { recursive: true }); - - await Promise.all([ - writeFile( - join(storyDir, "current_state.md"), - [ - "| Field | Value |", - "| --- | --- |", - "| Current Chapter | 10 |", - "| Current Goal | Continue tracing the mentor debt |", - "| Current Conflict | Mentor debt mainline vs guild safe route |", - "", - ].join("\n"), - "utf-8", - ), - writeFile( - join(storyDir, "pending_hooks.md"), - [ - "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |", - "| --- | --- | --- | --- | --- | --- | --- |", - "| mentor-debt | 1 | relationship | open | 10 | 16 | The mentor debt remains unresolved |", - "| guild-route | 1 | mystery | open | 9 | 12 | The guild keeps offering a safer road |", - "", - ].join("\n"), - "utf-8", - ), - writeFile( - join(storyDir, "chapter_summaries.md"), - [ - "| chapter | title | characters | events | stateChanges | hookActivity | mood | chapterType |", - "| --- | --- | --- | --- | --- | --- | --- | --- |", - "| 6 | Guild Pressure 6 | Lin Yue | Guild pressure keeps building around the safe route | Guild route remains noisy | guild-route probed | restrained | holding-pattern |", - "| 7 | Guild Pressure 7 | Lin Yue | Guild pressure keeps building around the safe route | Guild route remains noisy | guild-route probed | restrained | holding-pattern |", - "| 8 | Guild Pressure 8 | Lin Yue | Guild pressure keeps building around the safe route | Guild route remains noisy | guild-route probed | restrained | holding-pattern |", - "| 9 | Guild Pressure 9 | Lin Yue | Guild pressure keeps building around the safe route | Guild route remains noisy | guild-route probed | restrained | holding-pattern |", - "| 10 | Mentor Debt Echo 10 | Lin Yue | Lin Yue returns to the mentor debt trail and checks the oath token again | Commitment to the mentor debt hardens | mentor-debt advanced | tense | mainline |", - "", - ].join("\n"), - "utf-8", - ), - ]); - - vi.resetModules(); - vi.doMock("../state/memory-db.js", () => ({ - MemoryDB: class { - constructor() { - throw new Error("sqlite unavailable"); - } - }, - })); - const { retrieveMemorySelection: retrieveFallback } = await import("../utils/memory-retrieval.js"); - - const result = await retrieveFallback({ - bookDir, - chapterNumber: 11, - goal: "Pull focus back to the mentor debt and do not let the guild route overtake the mainline.", - outlineNode: "Handle guild noise without letting the guild route overtake the mentor-debt mainline.", - mustKeep: ["Lin Yue does not abandon the mentor debt."], - }); - - expect(result.dbPath).toBeUndefined(); - expect(result.summaries.map((summary) => summary.chapter)).toContain(10); - expect(result.summaries.at(-1)?.chapter).toBe(10); - }); - - it("keeps the 师债回响 chapter in markdown fallback mode for Chinese books", async () => { - root = await mkdtemp(join(tmpdir(), "inkos-memory-retrieval-zh-fallback-test-")); - const bookDir = join(root, "book"); - const storyDir = join(bookDir, "story"); - await mkdir(storyDir, { recursive: true }); - - await Promise.all([ - writeFile( - join(storyDir, "current_state.md"), - [ - "| 字段 | 值 |", - "| --- | --- |", - "| 当前章节 | 50 |", - "| 当前目标 | 继续追查师债 |", - "| 当前冲突 | 师债主线 vs 商会安全路线 |", - "", - ].join("\n"), - "utf-8", - ), - writeFile( - join(storyDir, "pending_hooks.md"), - [ - "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |", - "| --- | --- | --- | --- | --- | --- | --- |", - "| mentor-debt | 1 | relationship | open | 50 | 60 | 师债真相与誓令碎片持续绑定 |", - "| guild-route | 1 | mystery | open | 49 | 55 | 商会安全路线仍在诱导主角偏航 |", - "", - ].join("\n"), - "utf-8", - ), - writeFile( - join(storyDir, "chapter_summaries.md"), - [ - "| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |", - "| --- | --- | --- | --- | --- | --- | --- | --- |", - "| 46 | 商会余波46 | 林月 | 林月处理商会杂务与路引试探 | 继续压住商会支线 | guild-route 试探 | 克制 | 过渡牵制 |", - "| 47 | 商会余波47 | 林月 | 林月处理商会杂务与路引试探 | 继续压住商会支线 | guild-route 试探 | 克制 | 过渡牵制 |", - "| 48 | 商会余波48 | 林月 | 林月处理商会杂务与路引试探 | 继续压住商会支线 | guild-route 试探 | 克制 | 过渡牵制 |", - "| 49 | 商会余波49 | 林月 | 林月处理商会杂务与路引试探 | 继续压住商会支线 | guild-route 试探 | 克制 | 过渡牵制 |", - "| 50 | 师债回响50 | 林月 | 林月再次追查师债线索,并核对誓令碎片痕迹 | 对师债真相的执念更强 | mentor-debt 推进 | 紧绷 | 主线推进 |", - "", - ].join("\n"), - "utf-8", - ), - ]); - - vi.resetModules(); - vi.doMock("../state/memory-db.js", () => ({ - MemoryDB: class { - constructor() { - throw new Error("sqlite unavailable"); - } - }, - })); - const { retrieveMemorySelection: retrieveFallback } = await import("../utils/memory-retrieval.js"); - - const result = await retrieveFallback({ - bookDir, - chapterNumber: 51, - goal: "第51章把注意力拉回师债,不让商会路线盖过主线。", - outlineNode: "处理商会噪音,但不允许商会路线盖过师债主线。", - mustKeep: ["林月不会放弃师债。"], - }); - - expect(result.dbPath).toBeUndefined(); - expect(result.summaries.map((summary) => summary.chapter)).toContain(50); - expect(result.summaries.at(-1)?.chapter).toBe(50); - }); - - sqliteIt("uses existing sqlite summaries and hooks without requiring markdown truth files", async () => { - root = await mkdtemp(join(tmpdir(), "inkos-memory-retrieval-db-test-")); - const bookDir = join(root, "book"); - const storyDir = join(bookDir, "story"); - await mkdir(storyDir, { recursive: true }); - - await writeFile( - join(storyDir, "current_state.md"), - [ - "| Field | Value |", - "| --- | --- |", - "| Current Chapter | 9 |", - "| Current Conflict | Mentor debt mainline vs guild safe route |", - "", - ].join("\n"), - "utf-8", - ); - - const memoryDb = new MemoryDB(bookDir); - try { - memoryDb.upsertSummary({ - chapter: 9, - title: "Mentor Debt Echo", - characters: "Lin Yue", - events: "Lin Yue returns to the mentor debt trail", - stateChanges: "Commitment hardens", - hookActivity: "mentor-debt advanced", - mood: "tense", - chapterType: "mainline", - }); - memoryDb.upsertHook({ - hookId: "mentor-debt", - startChapter: 1, - type: "relationship", - status: "open", - lastAdvancedChapter: 9, - expectedPayoff: "16", - notes: "Mentor debt remains unresolved", - }); - } finally { - memoryDb.close(); - } - - const result = await retrieveMemorySelection({ - bookDir, - chapterNumber: 10, - goal: "Pull focus back to the mentor debt.", - mustKeep: ["Lin Yue does not abandon the mentor debt."], - }); - - expect(result.dbPath).toContain("memory.db"); - expect(result.summaries.map((summary) => summary.chapter)).toContain(9); - expect(result.hooks.map((hook) => hook.hookId)).toContain("mentor-debt"); - }); - - sqliteIt("backfills sqlite memory from structured state instead of stale markdown truth files", async () => { + it("backfills sqlite memory from structured state instead of stale markdown truth files", async () => { root = await mkdtemp(join(tmpdir(), "inkos-memory-retrieval-db-structured-test-")); const bookDir = join(root, "book"); const storyDir = join(bookDir, "story"); @@ -1211,7 +968,7 @@ describe("retrieveMemorySelection", () => { describe("parsePendingHooksMarkdown", () => { it("strips markdown emphasis from hook ids in pending hooks tables", () => { - const hooks = memoryRetrieval.parsePendingHooksMarkdown([ + const hooks = parsePendingHooksMarkdown([ "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | notes |", "| --- | --- | --- | --- | --- | --- | --- |", "| **H009** | 3 | mystery | open | 3 | 9 | Bold markdown leaked into hook id |", @@ -1223,7 +980,7 @@ describe("parsePendingHooksMarkdown", () => { }); it("parses semantic payoff timing from extended pending hooks tables", () => { - const hooks = memoryRetrieval.parsePendingHooksMarkdown([ + const hooks = parsePendingHooksMarkdown([ "| hook_id | start_chapter | type | status | last_advanced | expected_payoff | payoff_timing | notes |", "| --- | --- | --- | --- | --- | --- | --- | --- |", "| oath-debt | 8 | relationship | open | 12 | Reveal why the mentor broke the oath | slow-burn | Long-buried debt stays unresolved |", diff --git a/packages/core/src/__tests__/pipeline-runner.test.ts b/packages/core/src/__tests__/pipeline-runner.test.ts index ebc34fe7..b81963eb 100644 --- a/packages/core/src/__tests__/pipeline-runner.test.ts +++ b/packages/core/src/__tests__/pipeline-runner.test.ts @@ -1090,175 +1090,6 @@ describe("PipelineRunner", () => { } }); - it("uses a friendly fallback warning when sqlite memory indexing is unavailable", async () => { - const { logger, warnings } = createCaptureLogger(); - const { root, runner, state, bookId } = await createRunnerFixture({ - logger, - }); - - await Promise.all([ - writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n", "utf-8"), - writeFile( - join(state.bookDir(bookId), "story", "current_state.md"), - createStateCard({ - chapter: 0, - location: "Shrine outskirts", - protagonistState: "Lin Yue begins with the oath token hidden.", - goal: "Reach the trial city.", - conflict: "The trial deadline is closing in.", - }), - "utf-8", - ), - ]); - - vi.spyOn(memoryDbModule, "MemoryDB").mockImplementation(() => { - const error = new Error("No such built-in module: node:sqlite"); - (error as Error & { code?: string }).code = "ERR_UNKNOWN_BUILTIN_MODULE"; - throw error; - }); - vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue( - createWriterOutput({ - chapterNumber: 1, - content: "Draft body.", - wordCount: "Draft body.".length, - }), - ); - - try { - const result = await runner.writeDraft(bookId); - - expect(result.chapterNumber).toBe(1); - console.log("DEBUG warnings:", JSON.stringify(warnings, null, 2)); - expect(warnings).toContain( - "当前 Node 运行时不支持 SQLite 记忆索引,继续使用 Markdown 回退方案。", - ); - expect(warnings.join("\n")).not.toContain("node:sqlite"); - expect(warnings.join("\n")).not.toContain("ERR_UNKNOWN_BUILTIN_MODULE"); - expect(warnings.join("\n")).not.toContain("状态事实同步已跳过:"); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - it("does not misclassify generic runtime errors as sqlite-unavailable fallback", async () => { - const { logger, warnings } = createCaptureLogger(); - const { root, runner, state, bookId } = await createRunnerFixture({ - logger, - }); - - await Promise.all([ - writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n", "utf-8"), - writeFile( - join(state.bookDir(bookId), "story", "current_state.md"), - createStateCard({ - chapter: 0, - location: "Shrine outskirts", - protagonistState: "Lin Yue begins with the oath token hidden.", - goal: "Reach the trial city.", - conflict: "The trial deadline is closing in.", - }), - "utf-8", - ), - ]); - - vi.spyOn(memoryDbModule, "MemoryDB").mockImplementation(() => { - throw new Error("sync failed while handling cached node:sqlite telemetry text"); - }); - vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue( - createWriterOutput({ - chapterNumber: 1, - content: "Draft body.", - wordCount: "Draft body.".length, - }), - ); - - try { - const result = await runner.writeDraft(bookId); - - expect(result.chapterNumber).toBe(1); - expect(warnings.join("\n")).toContain("叙事记忆同步已跳过:"); - expect(warnings.join("\n")).not.toContain("当前 Node 运行时不支持 SQLite 记忆索引"); - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - - sqliteIt("recovers when sqlite-unavailable signature is transient and probe succeeds", async () => { - const { logger, warnings } = createCaptureLogger(); - const { root, runner, state, bookId } = await createRunnerFixture({ - logger, - inputGovernanceMode: "legacy", - }); - - await Promise.all([ - writeFile(join(state.bookDir(bookId), "story", "pending_hooks.md"), "# Pending Hooks\n", "utf-8"), - writeFile( - join(state.bookDir(bookId), "story", "current_state.md"), - createStateCard({ - chapter: 0, - location: "Shrine outskirts", - protagonistState: "Lin Yue begins with the oath token hidden.", - goal: "Reach the trial city.", - conflict: "The trial deadline is closing in.", - }), - "utf-8", - ), - ]); - - const RealMemoryDB = memoryDbModule.MemoryDB; - let constructorCalls = 0; - vi.spyOn(memoryDbModule, "MemoryDB").mockImplementation((...args: ConstructorParameters) => { - if (constructorCalls === 0) { - constructorCalls += 1; - const error = new Error("No such built-in module: node:sqlite"); - (error as Error & { code?: string }).code = "ERR_UNKNOWN_BUILTIN_MODULE"; - throw error; - } - constructorCalls += 1; - return new RealMemoryDB(...args); - }); - vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue( - createWriterOutput({ - chapterNumber: 1, - content: "Draft body.", - wordCount: "Draft body.".length, - chapterSummary: "| 1 | Draft summary | Lin Yue | Draft event | Draft shift | hook advanced | tense | transition |", - updatedHooks: [ - "# Pending Hooks", - "", - "| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |", - "| --- | --- | --- | --- | --- | --- | --- |", - "| mentor-debt | 1 | relationship | open | 1 | 3 | Draft hook |", - ].join("\n"), - }), - ); - - try { - const result = await runner.writeDraft(bookId); - - expect(result.chapterNumber).toBe(1); - expect(warnings.join("\n")).not.toContain("当前 Node 运行时不支持 SQLite 记忆索引"); - expect(warnings.join("\n")).not.toContain("叙事记忆同步已跳过"); - - const memoryDb = new MemoryDB(state.bookDir(bookId)); - try { - expect(memoryDb.getChapterCount()).toBe(1); - expect(memoryDb.getActiveHooks()).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - hookId: "mentor-debt", - status: "open", - }), - ]), - ); - } finally { - memoryDb.close(); - } - } finally { - await rm(root, { recursive: true, force: true }); - } - }); - sqliteIt("retries transient sqlite busy errors during narrative memory sync", async () => { const { logger, warnings } = createCaptureLogger(); const { root, runner, state, bookId } = await createRunnerFixture({ diff --git a/packages/core/src/__tests__/skill-agent-tool.test.ts b/packages/core/src/__tests__/skill-agent-tool.test.ts index 46adaae3..18b0d06f 100644 --- a/packages/core/src/__tests__/skill-agent-tool.test.ts +++ b/packages/core/src/__tests__/skill-agent-tool.test.ts @@ -32,7 +32,7 @@ describe("use_skill agent tool", () => { const activated: string[] = []; const tool = createUseSkillTool({ registry, - onActivate: (skillId) => activated.push(skillId), + onActivate: (activation) => activated.push(activation.skill.id), }); const result = await tool.execute("skill-1", { skillId: "writer-distillation" }); @@ -67,7 +67,7 @@ describe("use_skill agent tool", () => { const activated: string[] = []; const tool = createUseSkillTool({ registry, - onActivate: (skillId) => activated.push(skillId), + onActivate: (activation) => activated.push(activation.skill.id), }); const result = await tool.execute("skill-resource", { @@ -91,6 +91,50 @@ describe("use_skill agent tool", () => { expect(activated).toEqual(["writer-distillation"]); }); + it("retrieves relevant Skill references by natural-language query", async () => { + const baseDir = join(root, "long-writing"); + await mkdir(join(baseDir, "references"), { recursive: true }); + await writeFile( + join(baseDir, "references", "continuity.md"), + [ + "# 伏笔连续性", + "", + "师债必须通过誓令碎片和导师旧信继续推进。", + "", + "# 节奏", + "", + "日常章节允许降低冲突密度。", + ].join("\n"), + "utf-8", + ); + const registry = createSkillRegistry({ + skills: [{ + id: "long-writing", + name: "Long Writing", + description: "Long-form continuity guidance.", + body: "Retrieve only the reference needed by the current task.", + source: "external", + baseDir, + }], + }); + const tool = createUseSkillTool({ registry }); + + const result = await tool.execute("skill-search", { + skillId: "long-writing", + query: "导师旧信和誓令碎片的伏笔如何推进", + }); + + expect(result.content).toEqual([expect.objectContaining({ + text: expect.stringMatching(/references\/continuity\.md:[0-9]+-[0-9]+[\s\S]*师债必须通过誓令碎片/), + })]); + expect(result.details).toMatchObject({ + kind: "skill_activated", + skillId: "long-writing", + query: "导师旧信和誓令碎片的伏笔如何推进", + retrievedResources: [expect.objectContaining({ path: "references/continuity.md" })], + }); + }); + it("rejects resources reached through a symlinked parent directory", async () => { const baseDir = join(root, "writer-distillation"); const outsideDir = join(root, "outside"); diff --git a/packages/core/src/__tests__/writer-prompts.test.ts b/packages/core/src/__tests__/writer-prompts.test.ts index 3b894d66..413a4cde 100644 --- a/packages/core/src/__tests__/writer-prompts.test.ts +++ b/packages/core/src/__tests__/writer-prompts.test.ts @@ -32,7 +32,7 @@ const GENRE: GenreProfile = { }; describe("buildWriterSystemPrompt", () => { - it("includes writing methodology blocks in governed mode", () => { + it("keeps governed inputs and leaves reusable craft to the activated Skill", () => { const prompt = buildWriterSystemPrompt( BOOK, GENRE, @@ -50,27 +50,9 @@ describe("buildWriterSystemPrompt", () => { expect(prompt).toContain("## 输入治理契约"); expect(prompt).toContain("卷纲是默认规划"); - // v10: compact craft card replaces full methodology modules - expect(prompt).toContain("写作铁律"); - expect(prompt).toContain("盐溶于汤"); - expect(prompt).toContain("黄金3章"); - }); - - it("injects cross-theme prose-execution rules: simile restraint + dramatize the climax (zh)", () => { - const prompt = buildWriterSystemPrompt( - BOOK, GENRE, null, "", "", "", undefined, 5, "creative", undefined, "zh", "governed", - ); - expect(prompt).toContain("明喻节制"); - expect(prompt).toContain("高潮必须演出"); - expect(prompt).toContain("不许概述"); - }); - - it("injects cross-theme prose-execution rules into the English prompt", () => { - const prompt = buildWriterSystemPrompt( - { ...BOOK }, { ...GENRE, language: "en" }, null, "", "", "", undefined, 5, "creative", undefined, "en", "governed", - ); - expect(prompt).toContain("Simile restraint"); - expect(prompt).toContain("Play out the climax"); + expect(prompt).not.toContain("## 核心规则"); + expect(prompt).not.toContain("## 创作宪法"); + expect(prompt).not.toContain("## 代入感六支柱"); }); it("enforces narrative person only when the user explicitly set one (#290)", () => { @@ -144,67 +126,10 @@ describe("buildWriterSystemPrompt", () => { "governed", ); - expect(prompt).toContain("## 核心规则"); - expect(prompt).toContain("## 硬性禁令"); expect(prompt).toContain("Do not reveal the mastermind"); expect(prompt).toContain("Keep the prose restrained"); }); - it("injects the creative constitution and six pillars of immersion as prose (zh)", () => { - const prompt = buildWriterSystemPrompt( - BOOK, - GENRE, - null, - "# Book Rules", - "# Genre Body", - "# Style Guide", - undefined, - 3, - "creative", - undefined, - "zh", - "governed", - ); - - // Constitution and pillars appear as prose section headings. - expect(prompt).toContain("## 创作宪法"); - expect(prompt).toContain("## 代入感六支柱"); - // Constitution prose beats — verify a few load-bearing phrases ship. - expect(prompt).toContain("盐溶于汤"); - expect(prompt).toContain("全员智商在线"); - expect(prompt).toContain("拒绝流水账"); - // Pillar prose beats — ensure six-pillar content is present. - expect(prompt).toContain("基础信息标签化"); - expect(prompt).toContain("可视化熟悉感"); - expect(prompt).toContain("五感钩子"); - // Must NOT be rendered as a numbered checklist — writer must internalise. - expect(prompt).not.toContain("1. 基础信息标签化"); - expect(prompt).not.toContain("- 基础信息标签化"); - }); - - it("injects the creative constitution and six pillars of immersion as prose (en)", () => { - const prompt = buildWriterSystemPrompt( - { ...BOOK, language: "en" }, - { ...GENRE, language: "en", name: "General" }, - null, - "# Book Rules", - "# Genre Body", - "# Style Guide", - undefined, - 3, - "creative", - undefined, - "en", - "governed", - ); - - expect(prompt).toContain("## Creative Constitution"); - expect(prompt).toContain("## Six Pillars of Immersion"); - expect(prompt).toContain("salt in soup"); - expect(prompt).toContain("Refuse chronicle drift"); - expect(prompt).toContain("core tag plus one contrasting detail"); - }); - it("injects golden opening discipline into zh writer system prompt for ch<=3", () => { for (const ch of [1, 2, 3]) { const prompt = buildWriterSystemPrompt( diff --git a/packages/core/src/agent/agent-session.ts b/packages/core/src/agent/agent-session.ts index 21eb3a89..e61b09e3 100644 --- a/packages/core/src/agent/agent-session.ts +++ b/packages/core/src/agent/agent-session.ts @@ -73,6 +73,7 @@ import { assistantInvokesSkill, createUseSkillTool, sanitizeSkillTurnMessage, + type ActivatedSkillGuidance, } from "./skill-tool.js"; import { agentTrajectoryHeaders, @@ -176,7 +177,7 @@ interface CachedAgent { requestedIntent: AgentSessionConfig["requestedIntent"]; actionPayloadKey: string; skillResolutionKey: string; - turnSkillIds: Set; + turnSkills: Map; playWorldExists: boolean; language: string; modelIdentity: string; @@ -561,40 +562,6 @@ function extractTextFromAssistant(msg: AssistantMessage): string { .join(""); } -function looksLikeUnsavedChapterProse(text: string): boolean { - const trimmed = text.trim(); - if (trimmed.length < 800) return false; - if (looksLikeChapterRevisionPlan(trimmed)) return false; - return /(^|\n)\s{0,3}#{1,3}\s*(?:第\s*[0-9一二三四五六七八九十百千]+\s*章|Chapter\s+\d+)/i.test(trimmed); -} - -function looksLikeChapterRevisionPlan(text: string): boolean { - const headingLines = text - .split(/\n+/) - .filter((line) => /^\s{0,3}#{1,3}\s*(?:第\s*[0-9一二三四五六七八九十百千]+\s*章|Chapter\s+\d+)/i.test(line)) - .slice(0, 8); - if (headingLines.length === 0) return false; - const planSignals = [ - /修改指令|修订指令|重写指令|执行指令|改稿指令|具体修改|修改方案|修订方案|重写方案/, - /问题|目标|处理方式|调整|建议|要求|保留|删除|新增|强化|弱化/, - /revision brief|revision plan|rewrite plan|edit instruction|actionable instruction|change request/i, - ]; - return headingLines.some((line) => planSignals.some((signal) => signal.test(line))) - || planSignals.some((signal) => signal.test(text.slice(0, 1200))); -} - -function bookRawChapterBoundaryText(language: string): string { - return language === "zh" - ? "这次模型输出了疑似章节正文的聊天文本,但没有调用落盘工具。InkOS 不会把聊天正文当成已保存章节:如果要续写新章,请发送“继续写下一章”;如果要修改旧章,请发送“重写/修订第 N 章 + 具体要求”,系统会走 reviser/writer 管线落盘。" - : "The model produced chapter-like prose in chat without calling a persistence tool. InkOS will not treat chat prose as a saved chapter. Ask to write the next chapter only when you want to append; ask to rewrite/revise chapter N with concrete requirements when you want to change existing chapters."; -} - -function replaceAssistantText(message: AssistantMessage, text: string): void { - message.content = [{ type: "text", text }]; - message.stopReason = "stop"; - delete message.errorMessage; -} - function lastAssistantMessage(messages: AgentMessage[]): AssistantMessage | undefined { for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; @@ -830,6 +797,8 @@ type CreateAgentToolsForModeParams = { readonly playWorldExists: boolean; readonly intentSkillTool?: ReturnType; readonly requestedSkillIds?: () => ReadonlyArray; + readonly activeSkills?: () => ReadonlyArray; + readonly workerSkills?: (agent: string) => ReadonlyArray; }; function createAgentToolsForMode(params: CreateAgentToolsForModeParams) { @@ -842,6 +811,8 @@ function createModeTools(params: CreateAgentToolsForModeParams) { const subAgentTool = createSubAgentTool(params.pipeline, params.bookId, params.projectRoot, { actionPayload: params.actionPayload, language: lang, + activeSkills: params.activeSkills, + workerSkills: params.workerSkills, }); const proposalTool = createProposeActionTool(lang, { sameSession: params.sessionKind !== "chat", @@ -936,6 +907,8 @@ function createModeTools(params: CreateAgentToolsForModeParams) { actionPayload: params.actionPayload, architectCreateOnly: true, language: lang, + activeSkills: params.activeSkills, + workerSkills: params.workerSkills, })]; } return [proposalTool, researchTool, materialTool, materialRetrievalTool]; @@ -1109,7 +1082,12 @@ async function runAgentSessionUnlocked( ? plainToAgentMessages(initialMessages) : []; let terminalToolResultTail = false; - const turnSkillIds = new Set(skillResolution.forcedSkillIds); + const turnSkills = new Map( + skillResolution.usedSkills.map((skill) => [skill.id, { skill, resources: [] }]), + ); + const longWritingSkill = skillResolution.availableSkills.find( + (skill) => skill.id === "inkos-long-writing", + ); const allowIntentSkillSelection = actionSource === "free-text" && skillResolution.forcedSkillIds.length === 0; const baseSystemPrompt = buildAgentSystemPrompt(bookId, language, sessionKind, { @@ -1123,7 +1101,7 @@ async function runAgentSessionUnlocked( ? createUseSkillTool({ registry: skillRegistry, disabledSkillIds: skillResolution.disabledSkillIds, - onActivate: (skillId) => turnSkillIds.add(skillId), + onActivate: (activation) => turnSkills.set(activation.skill.id, activation), }) : undefined; const agentTools = createAgentToolsForMode({ @@ -1140,7 +1118,13 @@ async function runAgentSessionUnlocked( playMode, playWorldExists, intentSkillTool, - requestedSkillIds: () => [...turnSkillIds], + requestedSkillIds: () => [...turnSkills.keys()], + activeSkills: () => [...turnSkills.values()], + workerSkills: (agent) => ( + longWritingSkill && agent !== "exporter" + ? [{ skill: longWritingSkill, resources: [] }] + : [] + ), }); const agent = new Agent({ initialState: { @@ -1182,7 +1166,7 @@ async function runAgentSessionUnlocked( requestedIntent, actionPayloadKey, skillResolutionKey, - turnSkillIds, + turnSkills, playWorldExists, language, modelIdentity: requestedModelIdentity, @@ -1198,9 +1182,9 @@ async function runAgentSessionUnlocked( } cached.lastActive = Date.now(); - cached.turnSkillIds.clear(); - for (const skillId of skillResolution.forcedSkillIds) { - cached.turnSkillIds.add(skillId); + cached.turnSkills.clear(); + for (const skill of skillResolution.usedSkills) { + cached.turnSkills.set(skill.id, { skill, resources: [] }); } const { agent } = cached; const attachmentBlock = buildAttachmentUserBlock(config.attachments, language); @@ -1224,8 +1208,7 @@ async function runAgentSessionUnlocked( let parentUuid: string | null = null; let piTurnIndex = 0; let lastAssistantUuid: string | null = null; - let successfulProductionToolResultSeen = false; - let skillTurnActive = cached.turnSkillIds.size > 0; + let skillTurnActive = cached.turnSkills.size > 0; const persistAgentEvent = async (event: AgentEvent): Promise => { if (event.type === "turn_start") { @@ -1237,21 +1220,6 @@ async function runAgentSessionUnlocked( const role = transcriptRoleForMessage(event.message); if (!role) return; - if (role === "toolResult") { - const toolName = (event.message as { toolName?: unknown }).toolName; - const isError = (event.message as { isError?: unknown }).isError; - if (isTerminalProductionToolName(toolName) && isError !== true) { - successfulProductionToolResultSeen = true; - } - } - - if (role === "assistant" && sessionKind === "book" && !successfulProductionToolResultSeen) { - const assistant = event.message as AssistantMessage; - if (looksLikeUnsavedChapterProse(extractTextFromAssistant(assistant))) { - replaceAssistantText(assistant, bookRawChapterBoundaryText(language)); - } - } - if (assistantInvokesSkill(event.message)) skillTurnActive = true; const persistedMessage = sanitizeSkillTurnMessage(event.message, skillTurnActive); const uuid = randomUUID(); diff --git a/packages/core/src/agent/agent-system-prompt.ts b/packages/core/src/agent/agent-system-prompt.ts index bb0ba7c8..100bb78d 100644 --- a/packages/core/src/agent/agent-system-prompt.ts +++ b/packages/core/src/agent/agent-system-prompt.ts @@ -508,128 +508,42 @@ function buildBookPrompt(bookId: string, isZh: boolean): string { return isZh ? `你是 InkOS 写作助手,当前正在处理书籍「${bookId}」。 -## 权限边界 +## 结构边界 -- 当前书由 session 绑定为「${bookId}」。业务工具不要传其他 bookId;省略 bookId 时默认使用当前书。 -- 只围绕当前书读、写、审、改和导出。 -- 不要调用 architect 创建新书;如果用户想新建书,让用户回到首页开启新建流程。 -- 不要在当前书 session 内生成独立短篇或启动互动世界;如果用户要做这些,让他切换到 InkOS Short 或 InkOS Play。 -- read、grep、ls 只能用于读取和定位当前书内容;你没有直接改工程文件的权限。 +- 当前书由 session 绑定。只处理这本书;不要创建新书、独立短篇或互动世界,也不要尝试修改工程文件。 +- 工具 schema 是参数与能力的唯一说明,不要根据这段提示臆造参数或权限。 +- 用户在讨论、提问、比较方案时直接回答。只有用户明确要求产生副作用时才调用工具;不要把讨论猜成执行命令。 +- 用户最新指令是本轮任务方向。调用 sub_agent 时必须原样保留其目标、限制和纠偏要求,不能压成“润色一下”之类的泛化任务。 -## 可用工具 +## 动作边界 -- sub_agent:委托子智能体执行当前书重操作: - - agent="writer" 从最后一章继续顺序写,不能指定任意章节号。参数:chapterCount(连续写几章,1-20,默认 1)、chapterWordCount。 - - agent="auditor" 审计已有章节。参数:chapterNumber 指定第几章;不传则审最新章。 - - agent="reviser" 修改已有章节。必须传 chapterNumber。参数:chapterNumber, mode: spot-fix/polish/rewrite/rework/anti-detect。 - - agent="exporter" 导出书籍。参数:format: txt/md/epub, approvedOnly: true/false。 -- generate_cover:只生成或重做当前书/当前标题的封面图和封面提示词;不写正文。 -- read:读取设定文件或章节内容。 -- write_truth_file:覆盖当前书真相/设定文件。优先路径:outline/story_frame.md、outline/volume_map.md、roles/major/.md、roles/minor/.md;兼容 current_focus.md、author_intent.md、current_state.md。 -- 角色卡编辑走 write_truth_file,不走 patch_chapter_text:主要角色路径 roles/主要角色/<角色名>.md 或 roles/major/.md;次要角色路径 roles/次要角色/<角色名>.md 或 roles/minor/.md。改角色动机、关系、性格锁、禁忌、当前状态时,先读对应角色卡,保留未被用户要求改变的内容,再整卡覆盖。 -- rename_entity:统一改角色/实体名。 -- patch_chapter_text:对已有章节做局部定点修补。 -- replace_chapter_text:用户已经给出某章完整替换正文时,整章覆盖并标记复核;不要用它让模型自己生成新正文,模型生成型重写仍走 reviser。 -- delete_latest_chapter:仅当用户明确要求删除当前最后一章时调用;它会保留回收站副本并回滚故事状态。不得用于删除中间章节。 -- research_web:用户明确要求联网研究、事实核查、年代/职业/地域/制度资料时使用;报告保存为参考材料,不会自动改当前书设定或正文。 -- ingest_material:用户给 URL、上传 PDF/Markdown/文本资料,或要求“先读/归档这份资料”时使用;资料卡保存在 .inkos/materials,不会自动改当前书设定或正文。 -- retrieve_material:基于当前任务从 .inkos/materials 召回相关片段;返回带路径和字符范围的证据指针。它只读取参考资料,不改设定或正文。 -- manage_book_reference:把已归档资料按用户原话绑定到当前书,或查看/解除绑定。bind 时传 ingest_material 返回的精确 materialId,并把“借鉴开篇机制/人物关系/成长节奏”等用途原样写进 uses;不要套固定分类。绑定只影响后续写章时的按需参考,不复制原文、不自动改正典。 -- import_chapters:把用户提供的已有小说章节(本地文件或目录,路径可用“用户上传文件”区块里的 stored_path,也可以是用户给出的绝对路径)导入当前书成为正式章节,并逆向生成设定文件。目录模式按文件名排序、每个 .md/.txt 文件一章;单文件模式默认按“第X章/Chapter N”标题自动分章,可用 splitPattern 自定义正则。当前书已有章节时必须传 resumeFrom 续导,否则会报错。它和 ingest_material 的区别:ingest_material 只存参考资料,import_chapters 会写入章节和设定。 -- grep:搜索内容。 -- ls:列出文件或章节。 - -## 工具选择 - -- 不要在聊天回答里直接写章节正文;不能输出“# 第 N 章”或大段小说正文来冒充落盘结果。 -- 用户要求续写、写下一章、继续正文时,必须调用 sub_agent(agent="writer");不要先 read/ls 再自己写正文。 -- sub_agent 成功返回后,本轮直接结束。不要继续调用 read、ls、patch_chapter_text,也不要再补写正文。 -- 用户说“写下一章 / 继续写 / 再来一章” → sub_agent(agent="writer")。 -- 用户说“连续写 N 章 / 再写 N 章” → 只调用一次 sub_agent(agent="writer", chapterCount=N),不要重复或并发调用 writer。 -- 用户说“审第 N 章 / 看看这一章问题” → sub_agent(agent="auditor", chapterNumber=N)。 -- 极易出错:用户说“改 / 修订 / 重写第 N 章”、或“第 N 章哪里不好” → 必须用 sub_agent(agent="reviser", chapterNumber=N),不要用 writer;writer 只会续写新的下一章,不会修改旧章节。 -- 极易出错:用户说“写下一章 / 继续写 / 再来一章” → 才用 sub_agent(agent="writer"),不要把它理解成 reviser。 -- 明确执行命令不需要先 read/ls 预检查,直接调用对应 sub_agent;sub_agent 会读取必要上下文。 -- 用户没说章节号、只说“改刚才那章” → 先确认最新章节号或读取章节索引后再修。 -- 用户问设定相关问题 → 先 read,再回答。 -- 用户想改设定/真相文件 → write_truth_file。 -- 用户想改角色卡/人物设定 → 先 read 对应 roles 文件,再 write_truth_file 覆盖该角色卡。 -- 用户要求角色或实体改名 → rename_entity。 -- 用户要求某章内局部小修 → patch_chapter_text。 -- 用户粘贴/提供某章完整新正文并要求替换 → replace_chapter_text。 -- 用户要求生成或重做封面 → generate_cover。 -- 用户要求查外部事实、年代职业细节、真实地域制度资料 → research_web;用户提供 URL/PDF/文本资料 → ingest_material;用户要求基于已归档资料回答、对照、续写或整理 → retrieve_material;用户明确要求“以后写这本书时按某种用途参考这份资料” → manage_book_reference(action="bind")。如需把研究结果或资料内容写入正典设定,仍必须由用户明确确认后用 write_truth_file。 -- 用户要求把已有小说/章节/整本文稿导入当前书(成为正式章节并生成设定)→ import_chapters;只是提供参考资料、不要求进正文 → ingest_material。 -- 其他普通讨论 → 直接回答。 - -## 章节索引 - -章节索引在 \`books/${bookId}/chapters/index.json\`;章节文件在 \`books/${bookId}/chapters/\`,命名格式为 \`0001_标题.md\`。 - -如果索引和磁盘文件不一致,先说明不一致和建议修复方式;不要直接修改 index.json。 +- 续写新的下一章用 writer;修改、重写或重修已有章节用 reviser;审查已有章节用 auditor。三者不可互换。 +- 连续写多章只启动一次 writer 并传入章数,不要重复或并发启动。 +- 章节生产必须落盘:不要在聊天正文里输出章节来冒充完成。sub_agent 成功后结束本轮,完成态只以成功工具结果为准。 +- 用户给出明确旧文本和新文本时可做局部 patch;用户给出完整替换稿时可整章 replace;需要模型生成整章修改时必须走 reviser。 +- 修改设定或角色卡时先读取权威文件,再只改用户要求的部分;不要用章节编辑工具改正典。 +- 研究报告、资料卡和检索片段只是参考,不会自动成为正典。只有用户明确授权后才可写入设定;绑定资料时保留用户原话中的用途。 +- 缺少目标章节、对象或关键材料时,只问一个必要问题。 ${commonOutputRules(true)}` : `You are the InkOS writing assistant, working on book "${bookId}". -## Permission Boundary +## Structural Boundary -- The active book is session-bound to "${bookId}". Do not pass another bookId to business tools; omit bookId to use the active book. -- Work only on reading, writing, auditing, revising, and exporting the active book. -- Do not call architect to create a new book; ask the user to return home and start a new-book flow. -- Do not create standalone short fiction or start interactive worlds inside this active-book session; ask the user to switch to InkOS Short or InkOS Play. -- read, grep, and ls only read or locate active-book content; you do not have direct project-file editing permission. +- The active book is session-bound. Work only on this book; do not create another book, standalone short fiction, an interactive world, or edit project source files. +- Tool schemas are the sole contract for capabilities and arguments. Do not invent parameters or authority from this prompt. +- Answer discussion, questions, and option comparisons directly. Call a tool only when the user clearly requests a side effect; never infer an execution command from discussion. +- The latest user instruction is the task direction for this turn. Preserve its goals, constraints, and corrections when calling sub_agent instead of reducing it to a generic “polish this” request. -## Available Tools +## Action Boundary -- sub_agent: delegate active-book heavy operations: - - agent="writer" writes forward from the latest chapter. It cannot target an arbitrary chapter number. Params: chapterCount (1-20 consecutive chapters, default 1), chapterWordCount. - - agent="auditor" audits an existing chapter. Params: chapterNumber; omit for latest. - - agent="reviser" revises an existing chapter. chapterNumber is required. Params: chapterNumber, mode: spot-fix/polish/rewrite/rework/anti-detect. - - agent="exporter" exports the book. Params: format: txt/md/epub, approvedOnly: true/false. -- generate_cover: generate or regenerate only a cover image and cover prompt for the active book/current title; it does not write prose. -- read: read settings files or chapter content. -- write_truth_file: replace active-book truth/settings files. Prefer outline/story_frame.md, outline/volume_map.md, roles/major/.md, roles/minor/.md; flat files such as current_focus.md, author_intent.md, and current_state.md remain supported. -- Role-card edits use write_truth_file, not patch_chapter_text: major characters live under roles/major/.md or roles/主要角色/.md; minor characters under roles/minor/.md or roles/次要角色/.md. For character motive, relationship, personality lock, taboo, or current-state edits, read the role card first, preserve unchanged content, then replace that card. -- rename_entity: rename characters or entities. -- patch_chapter_text: apply a local chapter patch. -- replace_chapter_text: replace a whole chapter only when the user provides the complete replacement chapter text; mark it for review. Do not use it for model-generated rewrites — use reviser. -- delete_latest_chapter: use only when the user explicitly asks to delete the current latest chapter. It preserves a trash copy and rolls story state back; it cannot delete a middle chapter. -- research_web: collect web research or fact checks for era/profession/region/institution details. Reports are saved as reference material and do not automatically change canon or prose. -- ingest_material: archive a user-provided URL, uploaded PDF, Markdown, or text file into .inkos/materials. Material cards are references only and do not automatically change canon or prose. -- retrieve_material: retrieve task-relevant snippets from .inkos/materials with path and character-range evidence pointers. It reads reference materials only and does not change canon or prose. -- manage_book_reference: bind an archived material to the active book using the user's own stated purposes, list bindings, or unbind one. For bind, pass the exact materialId returned by ingest_material and preserve purposes such as opening mechanics, relationship dynamics, or growth pacing in uses; do not map them to a fixed taxonomy. Bindings affect future on-demand chapter context but do not copy prose or mutate canon. -- import_chapters: import the user's existing novel chapters (a local file or directory; the path can be the stored_path from the Uploaded Files block or an absolute path the user names) into the active book as real chapters, reverse-engineering the truth files. Directory mode imports each .md/.txt file as one chapter in filename order; single-file mode auto-splits on "第X章"/"Chapter N" headings, with splitPattern for a custom regex. When the book already has chapters, resumeFrom is required, otherwise it errors. Difference from ingest_material: ingest_material archives reference material only, import_chapters writes chapters and settings. -- grep: search content. -- ls: list files or chapters. - -## Tool Choice - -- Do not answer chapter-writing requests with raw chapter prose in chat; never output "# Chapter N" or a long fiction body as if it had been saved. -- When the user asks to continue or write the next chapter, you must call sub_agent(agent="writer"); do not read/list files first and then write prose yourself. -- After a successful sub_agent result, end the current turn immediately. Do not keep calling read, ls, patch_chapter_text, or add extra prose. -- "write next / continue / one more chapter" → sub_agent(agent="writer"). -- "write N consecutive chapters / write N more chapters" → call sub_agent once with agent="writer", chapterCount=N; never repeat or parallelize writer calls. -- "audit chapter N / review this chapter" → sub_agent(agent="auditor", chapterNumber=N). -- High-risk rule: "revise / fix / rewrite chapter N" or "chapter N has issues" → sub_agent(agent="reviser", chapterNumber=N), never writer. writer only appends a new next chapter; it does not edit an old chapter. -- High-risk rule: "write next / continue / one more chapter" → sub_agent(agent="writer"), not reviser. -- Clear execution commands do not need a read/ls preflight; call the matching sub_agent directly, because the sub-agent will load required context. -- If the user says "fix the chapter we just wrote" without a number, confirm the latest chapter number or read the chapter index first. -- Setting questions → read first, then answer. -- Setting/truth-file changes → write_truth_file. -- Character-card/person-setting changes → read the matching roles file first, then write_truth_file. -- Character/entity renames → rename_entity. -- Local chapter edits → patch_chapter_text. -- User-provided full replacement for an existing chapter → replace_chapter_text. -- Cover generation/regeneration → generate_cover. -- External facts, era/profession details, or real-world regional/institutional references → research_web. User-provided URLs/PDF/text files → ingest_material. Archived-material questions, comparisons, continuations, or summaries → retrieve_material. An explicit request to use an archived asset for a stated purpose in future chapters → manage_book_reference(action="bind"). If research or material content should affect canon, wait for explicit confirmation and then use write_truth_file. -- The user wants existing novel chapters or a full manuscript imported into the active book (as real chapters with reverse-engineered settings) → import_chapters. The user only provides reference material without asking it to become prose → ingest_material. -- Ordinary discussion → answer directly. - -## Chapter Index - -The chapter index is at \`books/${bookId}/chapters/index.json\`; chapter files are under \`books/${bookId}/chapters/\`, named \`0001_Title.md\`. - -If the index and files disagree, explain the inconsistency and suggested repair first; do not directly modify index.json. +- Use writer only to append the next chapter, reviser to change or rewrite an existing chapter, and auditor to review an existing chapter. Never substitute one for another. +- 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. +- 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. ${commonOutputRules(false)}`; } diff --git a/packages/core/src/agent/agent-tools.ts b/packages/core/src/agent/agent-tools.ts index 9a69d64d..bd4a85ed 100644 --- a/packages/core/src/agent/agent-tools.ts +++ b/packages/core/src/agent/agent-tools.ts @@ -33,7 +33,6 @@ import { PlayStore } from "../play/play-store.js"; import type { AgentContext } from "../agents/base.js"; import { ActionPayloadSchema, - isUsablePlayInitialScene, shortRunCharsPerChapterError, shortRunCharsPerChapterRange, type ActionPayload, @@ -44,6 +43,7 @@ import { runAsWorkflowTrajectory, runWithAgentTrajectoryRole, } from "../llm/agent-trajectory.js"; +import type { ActivatedSkillGuidance } from "./skill-tool.js"; // --------------------------------------------------------------------------- // Helpers @@ -404,7 +404,7 @@ function compactPlayStartPayload(value: ProposeActionParamsType["playStart"]): N if (visualContract) out.visualContract = visualContract; if (value.mode) out.mode = value.mode; const initialScene = value.initialScene?.trim(); - if (isUsablePlayInitialScene(initialScene)) out.initialScene = initialScene; + if (initialScene) out.initialScene = initialScene; const suggestedActions = normalizeSuggestedActions(value.suggestedActions); if (suggestedActions.length > 0) out.suggestedActions = suggestedActions; return Object.keys(out).length > 0 ? out : undefined; @@ -660,21 +660,39 @@ function prepareSubAgentArguments(args: unknown): SubAgentParamsType { return prepared as SubAgentParamsType; } +function runPipelineWithAgentContext( + pipeline: PipelineRunner, + signal: AbortSignal | undefined, + activatedSkills: ReadonlyArray, + task: () => Promise, +): Promise { + // PipelineRunner owns cancellation and professional-guidance scope. The + // fallback keeps lightweight embedders/test doubles source-compatible. + const runner = pipeline as PipelineRunner & { + runWithAgentContext?: ( + context: { + readonly signal?: AbortSignal; + readonly activatedSkills?: ReadonlyArray; + }, + activeTask: () => Promise, + ) => Promise; + runWithAbortSignal?: (activeSignal: AbortSignal | undefined, activeTask: () => Promise) => Promise; + }; + return runAsWorkflowTrajectory(() => ( + runner.runWithAgentContext + ? runner.runWithAgentContext({ signal, activatedSkills }, task) + : runner.runWithAbortSignal + ? runner.runWithAbortSignal(signal, task) + : task() + )); +} + function runPipelineWithAbortSignal( pipeline: PipelineRunner, signal: AbortSignal | undefined, task: () => Promise, ): Promise { - // PipelineRunner owns the async cancellation scope. The fallback keeps - // lightweight embedders/test doubles source-compatible. - const runner = pipeline as PipelineRunner & { - runWithAbortSignal?: (activeSignal: AbortSignal | undefined, activeTask: () => Promise) => Promise; - }; - return runAsWorkflowTrajectory(() => ( - runner.runWithAbortSignal - ? runner.runWithAbortSignal(signal, task) - : task() - )); + return runPipelineWithAgentContext(pipeline, signal, [], task); } export function createSubAgentTool( @@ -685,6 +703,8 @@ export function createSubAgentTool( readonly actionPayload?: ActionPayload; readonly architectCreateOnly?: boolean; readonly language?: "zh" | "en"; + readonly activeSkills?: () => ReadonlyArray; + readonly workerSkills?: (agent: string) => ReadonlyArray; } = {}, ): AgentTool { const sessionIsZh = (options.language ?? "zh") !== "en"; @@ -706,6 +726,11 @@ export function createSubAgentTool( ): Promise> { return runWithAgentTrajectoryRole("subagent", async () => { const { agent, instruction, bookId, title, chapterNumber, chapterCount, genre, platform, language, targetChapters, chapterWordCount, revise, feedback, mode, format, approvedOnly } = params; + const activatedSkills = mergeActivatedSkills( + options.workerSkills?.(agent) ?? [], + options.activeSkills?.() ?? [], + ); + const skillIds = activatedSkills.map((activation) => activation.skill.id); const progress = (msg: string) => { onUpdate?.(textResult(msg)); @@ -735,9 +760,10 @@ export function createSubAgentTool( } const targetBookId = resolveToolBookId("architect", bookId, activeBookId); progress(`Revising foundation for "${targetBookId}"...`); - await runPipelineWithAbortSignal( + await runPipelineWithAgentContext( pipeline, _signal, + activatedSkills, () => pipeline.reviseFoundation(targetBookId, feedback ?? instruction), ); progress(`Foundation revised for "${targetBookId}".`); @@ -760,9 +786,10 @@ export function createSubAgentTool( const now = new Date().toISOString(); const resolvedLanguage = createBookPayload?.language ?? language ?? inferLanguage(instruction); progress(`Starting architect for book "${id}"...`); - await runPipelineWithAbortSignal( + await runPipelineWithAgentContext( pipeline, _signal, + activatedSkills, () => pipeline.initBook( { id, @@ -782,7 +809,7 @@ export function createSubAgentTool( progress(`Architect finished — book "${id}" foundation created.`); return textResult( `Book "${resolvedTitle}" (${id}) initialised successfully. Foundation files are ready.`, - { kind: "book_created", bookId: id, title: resolvedTitle }, + { kind: "book_created", bookId: id, title: resolvedTitle, skillIds }, ); } @@ -791,11 +818,13 @@ export function createSubAgentTool( const requestedCount = chapterCount ?? 1; if (requestedCount > 1) { progress(`Writing ${requestedCount} consecutive chapters for "${targetBookId}"...`); - const results = await runPipelineWithAbortSignal( + const results = await runPipelineWithAgentContext( pipeline, _signal, + activatedSkills, () => pipeline.writeChapters(targetBookId, requestedCount, { wordCount: chapterWordCount, + externalContext: instruction, onChapterComplete(result, completedCount, totalCount) { progress(`Writer finished chapter ${result.chapterNumber} (${completedCount}/${totalCount}) for "${targetBookId}".`); }, @@ -812,21 +841,24 @@ export function createSubAgentTool( bookId: targetBookId, requestedCount, completedCount: results.length, + skillIds, chapters: results.map((result) => ({ chapterNumber: result.chapterNumber, title: result.title, wordCount: result.wordCount, status: result.status, + ...(result.contextTrace ? { contextTrace: result.contextTrace } : {}), })), ...(stoppedStatus ? { stoppedStatus } : {}), }, ); } progress(`Writing next chapter for "${targetBookId}"...`); - const result = await runPipelineWithAbortSignal( + const result = await runPipelineWithAgentContext( pipeline, _signal, - () => pipeline.writeNextChapter(targetBookId, chapterWordCount), + activatedSkills, + () => pipeline.writeNextChapter(targetBookId, chapterWordCount, undefined, instruction), ); progress(`Writer finished chapter for "${targetBookId}".`); const resultStatus = (result as any).status; @@ -845,6 +877,8 @@ export function createSubAgentTool( title: titleResult, wordCount, status: resultStatus, + skillIds, + ...(result.contextTrace ? { contextTrace: result.contextTrace } : {}), }, ); } @@ -852,9 +886,10 @@ export function createSubAgentTool( case "auditor": { const targetBookId = resolveToolBookId("auditor", bookId, activeBookId); progress(`Auditing chapter ${chapterNumber ?? "latest"} for "${targetBookId}"...`); - const audit = await runPipelineWithAbortSignal( + const audit = await runPipelineWithAgentContext( pipeline, _signal, + activatedSkills, () => pipeline.auditDraft(targetBookId, chapterNumber), ); progress(`Audit complete for "${targetBookId}".`); @@ -864,6 +899,14 @@ export function createSubAgentTool( return textResult( `Audit chapter ${audit.chapterNumber}: ${audit.passed ? "PASSED" : "FAILED"}, ${(audit.issues ?? []).length} issue(s).` + (issueLines ? `\n${issueLines}` : ""), + { + kind: "chapter_audit", + bookId: targetBookId, + chapterNumber: audit.chapterNumber, + passed: audit.passed, + issueCount: (audit.issues ?? []).length, + skillIds, + }, ); } @@ -871,9 +914,10 @@ export function createSubAgentTool( const targetBookId = resolveToolBookId("reviser", bookId, activeBookId); const resolvedMode: ReviseMode = (mode as ReviseMode) ?? "spot-fix"; progress(`Revising "${targetBookId}" chapter ${chapterNumber ?? "latest"} in ${resolvedMode} mode...`); - const result = await runPipelineWithAbortSignal( + const result = await runPipelineWithAgentContext( pipeline, _signal, + activatedSkills, () => pipeline.reviseDraft(targetBookId, chapterNumber, resolvedMode, instruction), ); const applied = result.applied !== false; @@ -889,6 +933,7 @@ export function createSubAgentTool( fixedIssues: result.fixedIssues, skippedReason: result.skippedReason, revisionDiagnostics: result.revisionDiagnostics, + skillIds, }; if (!applied) { progress(`Revision not applied for "${targetBookId}".`); @@ -923,16 +968,10 @@ export function createSubAgentTool( case "exporter": { const targetBookId = resolveToolBookId("exporter", bookId, activeBookId); if (!projectRoot) return textResult("Error: exporter requires projectRoot."); - const inferredFormat = format ?? (/epub/i.test(instruction) - ? "epub" - : /markdown|\bmd\b/i.test(instruction) - ? "md" - : "txt"); - const exportApprovedOnly = approvedOnly ?? /approved|已通过|通过章节/.test(instruction); const state = new StateManager(projectRoot); const result = await writeExportArtifact(state, targetBookId, { - format: inferredFormat, - approvedOnly: exportApprovedOnly, + format: format ?? "txt", + approvedOnly: approvedOnly ?? false, }); return textResult( `Exported "${targetBookId}": ${result.chaptersExported} chapters, ${result.totalWords} words → ${result.outputPath}`, @@ -968,6 +1007,16 @@ export function createSubAgentTool( }; } +function mergeActivatedSkills( + ...groups: ReadonlyArray> +): ActivatedSkillGuidance[] { + const merged = new Map(); + for (const group of groups) { + for (const activation of group) merged.set(activation.skill.id, activation); + } + return [...merged.values()]; +} + // --------------------------------------------------------------------------- // 2. Research Tool (research_web) // --------------------------------------------------------------------------- @@ -2099,9 +2148,7 @@ export function createPlayStartTool( const premise = playPayload?.premise ?? params.premise; const worldContract = playPayload?.worldContract ?? params.worldContract; const visualContract = playPayload?.visualContract ?? params.visualContract; - const initialScene = isUsablePlayInitialScene(playPayload?.initialScene) - ? playPayload?.initialScene - : params.initialScene; + const initialScene = playPayload?.initialScene?.trim() || params.initialScene; const playLanguage = inferLanguage([title, premise, worldContract, visualContract, initialScene].filter(Boolean).join("\n")); const world = await store.createWorld({ id: worldId, diff --git a/packages/core/src/agent/skill-tool.ts b/packages/core/src/agent/skill-tool.ts index af739512..ab677c02 100644 --- a/packages/core/src/agent/skill-tool.ts +++ b/packages/core/src/agent/skill-tool.ts @@ -1,9 +1,14 @@ -import { lstat, readFile } from "node:fs/promises"; +import { lstat, readFile, readdir } from "node:fs/promises"; import { join, relative, sep } from "node:path"; import { Type, type Static } from "@mariozechner/pi-ai"; import type { AgentMessage, AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { SkillRegistry } from "../skills/index.js"; +import type { AgentSkill, SkillRegistry } from "../skills/index.js"; import { safeChildPath } from "../utils/path-safety.js"; +import { + LocalSearchIndex, + splitMarkdownForSearch, + type SearchDocument, +} from "../retrieval/local-search.js"; const MAX_SKILL_RESOURCE_BYTES = 512 * 1024; const EXPIRED_SKILL_GUIDANCE = "This skill was used for its original turn only. Its instructions are not active for later turns."; @@ -15,6 +20,9 @@ const UseSkillParams = Type.Object({ resourcePath: Type.Optional(Type.String({ description: "Optional relative text resource inside the skill folder, after the main skill has been activated.", })), + query: Type.Optional(Type.String({ + description: "Natural-language query for retrieving relevant sections from this skill's references. Prefer this when the exact resource path is unknown.", + })), }); type UseSkillParamsType = Static; @@ -22,7 +30,20 @@ type UseSkillParamsType = Static; export interface CreateUseSkillToolOptions { readonly registry: SkillRegistry; readonly disabledSkillIds?: ReadonlyArray; - readonly onActivate?: (skillId: string) => void; + readonly onActivate?: (activation: ActivatedSkillGuidance) => void; +} + +export interface ActivatedSkillResource { + readonly path: string; + readonly heading?: string; + readonly body: string; + readonly charStart: number; + readonly charEnd: number; +} + +export interface ActivatedSkillGuidance { + readonly skill: AgentSkill; + readonly resources: ReadonlyArray; } export function createUseSkillTool( @@ -44,6 +65,14 @@ export function createUseSkillTool( throw new Error(`Skill is not available: ${skillId}`); } let resource: { readonly path: string; readonly body: string } | undefined; + let retrievedResources: ReadonlyArray<{ + readonly path: string; + readonly heading: string; + readonly body: string; + readonly charStart: number; + readonly charEnd: number; + readonly score: number; + }> = []; if (params.resourcePath?.trim()) { if (!skill.baseDir) { throw new Error(`Skill has no readable resource directory: ${skill.id}`); @@ -60,9 +89,30 @@ export function createUseSkillTool( throw new Error(`Skill resource is not UTF-8 text: ${resourcePath}`); } resource = { path: resourcePath, body }; + } else if (params.query?.trim()) { + if (!skill.baseDir) { + throw new Error(`Skill has no readable resource directory: ${skill.id}`); + } + retrievedResources = await retrieveSkillResources(skill.id, skill.baseDir, params.query.trim()); } - options.onActivate?.(skill.id); + options.onActivate?.({ + skill, + resources: resource + ? [{ + path: resource.path, + body: resource.body, + charStart: 0, + charEnd: resource.body.length, + }] + : retrievedResources.map(({ path, heading, body, charStart, charEnd }) => ({ + path, + heading, + body, + charStart, + charEnd, + })), + }); return textResult( [ `Skill activated: ${skill.id}`, @@ -76,6 +126,16 @@ export function createUseSkillTool( resource.body, ] : []), + ...(retrievedResources.length > 0 + ? [ + "", + "Relevant static references:", + ...retrievedResources.flatMap((item) => [ + `## ${item.path}:${item.charStart}-${item.charEnd}${item.heading ? ` · ${item.heading}` : ""}`, + item.body, + ]), + ] + : []), "", "This skill provides instructions only. Continue using the current session's existing tools and confirmation rules.", ].filter(Boolean).join("\n"), @@ -83,12 +143,85 @@ export function createUseSkillTool( kind: "skill_activated", skillId: skill.id, ...(resource ? { resourcePath: resource.path } : {}), + ...(params.query?.trim() + ? { + query: params.query.trim(), + retrievedResources: retrievedResources.map(({ path, heading, charStart, charEnd, score }) => ({ + path, + heading, + charStart, + charEnd, + score, + })), + } + : {}), }, ); }, }; } +async function retrieveSkillResources(skillId: string, baseDir: string, query: string) { + const files = await listSkillTextFiles(baseDir); + const documents: SearchDocument[] = []; + for (const path of files) { + const fullPath = safeChildPath(baseDir, path); + const info = await lstatWithoutSymlinks(baseDir, fullPath); + if (!info.isFile() || info.size > MAX_SKILL_RESOURCE_BYTES) continue; + const body = await readFile(fullPath, "utf-8"); + if (body.includes("\0")) continue; + splitMarkdownForSearch(body).forEach((segment, index) => { + documents.push({ + id: `skill:${skillId}:${path}:${index}`, + scope: `skill:${skillId}`, + kind: "skill-reference", + source: `${path}:${segment.charStart}-${segment.charEnd}`, + title: [path, segment.heading].filter(Boolean).join(" · "), + body: segment.body, + metadata: { + path, + heading: segment.heading, + charStart: segment.charStart, + charEnd: segment.charEnd, + }, + }); + }); + } + + const index = new LocalSearchIndex(":memory:"); + try { + const scope = `skill:${skillId}`; + index.replaceScope(scope, documents); + return index.search(query, { scope, limit: 4 }).map((hit) => ({ + path: String(hit.metadata?.path ?? ""), + heading: String(hit.metadata?.heading ?? ""), + body: hit.body, + charStart: Number(hit.metadata?.charStart ?? 0), + charEnd: Number(hit.metadata?.charEnd ?? hit.body.length), + score: hit.score, + })); + } finally { + index.close(); + } +} + +async function listSkillTextFiles(root: string, current = root): Promise { + const files: string[] = []; + for (const entry of await readdir(current, { withFileTypes: true })) { + const fullPath = join(current, entry.name); + const relativePath = relative(root, fullPath); + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory()) { + files.push(...await listSkillTextFiles(root, fullPath)); + continue; + } + if (!entry.isFile() || relativePath === "SKILL.md") continue; + if (!/\.(?:md|txt)$/i.test(entry.name)) continue; + files.push(relativePath); + } + return files.sort(); +} + function textResult(text: string, details: T): AgentToolResult { return { content: [{ type: "text", text }], details }; } diff --git a/packages/core/src/agents/base.ts b/packages/core/src/agents/base.ts index ce335eea..e0469800 100644 --- a/packages/core/src/agents/base.ts +++ b/packages/core/src/agents/base.ts @@ -3,6 +3,7 @@ import { chatCompletion } from "../llm/provider.js"; import { appendPromptPackGuidance } from "../prompts/prompt-pack.js"; import { searchWeb, fetchUrl } from "../utils/web-search.js"; import type { Logger } from "../utils/logger.js"; +import type { ActivatedSkillGuidance } from "../agent/skill-tool.js"; export interface AgentContext { readonly client: LLMClient; @@ -12,6 +13,7 @@ export interface AgentContext { readonly logger?: Logger; readonly onStreamProgress?: OnStreamProgress; readonly signal?: AbortSignal; + readonly activatedSkills?: ReadonlyArray; } export abstract class BaseAgent { @@ -29,7 +31,10 @@ export abstract class BaseAgent { messages: ReadonlyArray, options?: { readonly temperature?: number; readonly maxTokens?: number }, ): Promise { - return chatCompletion(this.ctx.client, this.ctx.model, messages, { + return chatCompletion(this.ctx.client, this.ctx.model, appendActivatedSkillGuidance( + messages, + this.ctx.activatedSkills, + ), { ...options, onStreamProgress: this.ctx.onStreamProgress, signal: this.ctx.signal, @@ -54,7 +59,10 @@ export abstract class BaseAgent { ): Promise { // OpenAI has native search — use it directly if (this.ctx.client.provider === "openai") { - return chatCompletion(this.ctx.client, this.ctx.model, messages, { + return chatCompletion(this.ctx.client, this.ctx.model, appendActivatedSkillGuidance( + messages, + this.ctx.activatedSkills, + ), { ...options, webSearch: true, onStreamProgress: this.ctx.onStreamProgress, @@ -109,3 +117,29 @@ export abstract class BaseAgent { abstract get name(): string; } + +function appendActivatedSkillGuidance( + messages: ReadonlyArray, + activations: ReadonlyArray | undefined, +): ReadonlyArray { + if (!activations || activations.length === 0) return messages; + const guidance = [ + "## Activated professional skills", + "Use this specialist methodology for the current operation. It is not author intent, canon, an output-format override, or permission to mutate anything outside the active operation.", + ...activations.flatMap(({ skill, resources }) => [ + `### ${skill.id} — ${skill.name}`, + skill.body.trim() || skill.description, + ...resources.flatMap((resource) => [ + `#### Reference: ${resource.path}:${resource.charStart}-${resource.charEnd}${resource.heading ? ` · ${resource.heading}` : ""}`, + resource.body, + ]), + ]), + ].join("\n\n"); + const systemIndex = messages.findIndex((message) => message.role === "system"); + if (systemIndex < 0) { + return [{ role: "system", content: guidance }, ...messages]; + } + return messages.map((message, index) => index === systemIndex + ? { ...message, content: `${message.content}\n\n${guidance}` } + : message); +} diff --git a/packages/core/src/agents/composer.ts b/packages/core/src/agents/composer.ts index d73842c1..650750ba 100644 --- a/packages/core/src/agents/composer.ts +++ b/packages/core/src/agents/composer.ts @@ -12,6 +12,9 @@ import type { PlanChapterOutput } from "./planner.js"; import { parseChapterSummariesMarkdown, retrieveMemorySelection, + type MemoryRetrievalTrace, + type MemorySemanticSelectionRequest, + type MemorySemanticSelector, } from "../utils/memory-retrieval.js"; import { buildGovernedRuleStack, @@ -36,6 +39,7 @@ export interface ComposeChapterInput { readonly compressibleContextCompiler?: CompressibleContextCompiler; readonly outlineSectionSelector?: OutlineSectionSelector; readonly referenceContextProvider?: BookReferenceContextProvider; + readonly memorySemanticSelector?: MemorySemanticSelector; readonly onContextCompression?: ContextCompressionCallback; } @@ -94,9 +98,10 @@ export async function composeGovernedChapter(input: ComposeChapterInput): Promis input.plan, input.book.language ?? "zh", input.outlineSectionSelector, + input.memorySemanticSelector, ); const referenceContext = await loadReferenceContext(input); - const selectedContext = [...baseContext, ...referenceContext.entries]; + const selectedContext = [...baseContext.entries, ...referenceContext.entries]; const initialContextPackage = ContextPackageSchema.parse({ chapter: input.chapterNumber, selectedContext, @@ -120,6 +125,14 @@ export async function composeGovernedChapter(input: ComposeChapterInput): Promis composerInputs: [input.plan.runtimePath], notes: [...referenceContext.notes, ...budgeted.notes], compression: budgeted.compression, + retrieval: { + engine: baseContext.retrievalTrace.engine, + query: baseContext.retrievalTrace.query, + candidates: baseContext.retrievalTrace.candidates.map((candidate) => ({ ...candidate })), + ...(baseContext.retrievalTrace.semanticSelectedIds + ? { semanticSelectedIds: [...baseContext.retrievalTrace.semanticSelectedIds] } + : {}), + }, }); const { contextPath, @@ -336,9 +349,47 @@ export class ComposerAgent extends BaseAgent { compressibleContextCompiler: input.compressibleContextCompiler ?? (contextBudget ? (request) => this.compileCompressibleContext(request) : undefined), outlineSectionSelector: input.outlineSectionSelector ?? ((request) => this.selectOutlineSections(request)), + memorySemanticSelector: input.memorySemanticSelector ?? ((request) => this.selectMemoryCandidates(request)), }); } + async selectMemoryCandidates(request: MemorySemanticSelectionRequest): Promise> { + const candidates = request.candidates.map((candidate, index) => [ + `#${index + 1} ${candidate.id}`, + `kind: ${candidate.kind}`, + `source: ${candidate.source}`, + `title: ${candidate.title}`, + candidate.excerpt, + ].join("\n")).join("\n\n"); + const response = await this.chat([ + { + role: "system", + content: [ + "You are InkOS's semantic story-memory selector.", + "Select only candidate memories that materially help the current chapter task. Understand negation, corrections, causal relationships, aliases, and paraphrases; do not rank by keyword overlap.", + "Established current-state facts and active hook lifecycle are protected separately by the host, so do not invent ids or retain unrelated candidates just to be safe.", + "Return strict JSON only: {\"selectedSources\":[\"candidate-id\"]}.", + ].join("\n"), + }, + { + role: "user", + content: [ + `Chapter: ${request.chapterNumber}`, + "Current task:", + request.query, + "", + "BM25 candidates:", + candidates, + ].join("\n"), + }, + ], { + temperature: 0.1, + maxTokens: 2048, + }); + const allowed = new Set(request.candidates.map((candidate) => candidate.id)); + return parseSelectedSources(response.content).filter((id) => allowed.has(id)); + } + async selectOutlineSections(request: OutlineSectionSelectionRequest): Promise> { if (request.candidates.length <= 1) { return request.candidates.map((candidate) => candidate.source); @@ -523,7 +574,11 @@ async function collectSelectedContext( plan: PlanChapterOutput, language: "zh" | "en", outlineSectionSelector?: OutlineSectionSelector, -): Promise { + memorySemanticSelector?: MemorySemanticSelector, +): Promise<{ + readonly entries: ContextPackage["selectedContext"]; + readonly retrievalTrace: MemoryRetrievalTrace; +}> { const retrievalHints = deriveRetrievalHints(plan); const memoBodyExcerpt = plan.memo.body.trim(); const chapterMemoEntry = memoBodyExcerpt.length > 0 @@ -604,6 +659,7 @@ async function collectSelectedContext( goal: plan.intent.goal, outlineNode: plan.intent.outlineNode, mustKeep: retrievalHints, + semanticSelector: memorySemanticSelector, }); const hookDebtEntries = await buildHookDebtEntries( storyDir, @@ -637,18 +693,21 @@ async function collectSelectedContext( excerpt: `${summary.heading} | ${summary.content}`, })); - return [ - ...chapterMemoEntry, - ...entries.filter((entry): entry is NonNullable => entry !== null), - ...outlineEntries, - ...canonEntries.filter((entry): entry is NonNullable => entry !== null), - ...trailEntries, - ...hookDebtEntries, - ...factEntries, - ...summaryEntries, - ...volumeSummaryEntries, - ...hookEntries, - ]; + return { + entries: [ + ...chapterMemoEntry, + ...entries.filter((entry): entry is NonNullable => entry !== null), + ...outlineEntries, + ...canonEntries.filter((entry): entry is NonNullable => entry !== null), + ...trailEntries, + ...hookDebtEntries, + ...factEntries, + ...summaryEntries, + ...volumeSummaryEntries, + ...hookEntries, + ], + retrievalTrace: memorySelection.retrievalTrace, + }; } function deriveRetrievalHints(plan: PlanChapterOutput): string[] { diff --git a/packages/core/src/agents/writer-prompts.ts b/packages/core/src/agents/writer-prompts.ts index 3898d2e4..4b9565d1 100644 --- a/packages/core/src/agents/writer-prompts.ts +++ b/packages/core/src/agents/writer-prompts.ts @@ -3,7 +3,7 @@ import type { GenreProfile } from "../models/genre-profile.js"; import type { BookRules } from "../models/book-rules.js"; import type { LengthSpec } from "../models/length-governance.js"; import { buildFanficCanonSection, buildCharacterVoiceProfiles, buildFanficModeInstructions } from "./fanfic-prompt-sections.js"; -import { buildEnglishCoreRules, buildEnglishAntiAIRules, buildEnglishCharacterMethod, buildEnglishPreWriteChecklist, buildEnglishGenreIntro } from "./en-prompt-sections.js"; +import { buildEnglishGenreIntro } from "./en-prompt-sections.js"; import { buildLengthSpec } from "../utils/length-metrics.js"; export interface FanficContext { @@ -46,14 +46,9 @@ export function buildWriterSystemPrompt( const sections = isEnglish ? [ buildEnglishGenreIntro(book, genreProfile), - buildEnglishCoreRules(book), buildGovernedInputContract("en", governed), buildChapterMemoContract("en", governed), buildLengthGuidance(resolvedLengthSpec, "en"), - buildWritingCraftCard("en"), - buildProseExecutionRules("en"), - buildCreativeConstitution("en"), - buildImmersionPillars("en"), buildGoldenOpeningDiscipline(chapterNumber, "en"), buildGenreRules(genreProfile, genreBody), buildProtagonistRules(bookRules), @@ -69,16 +64,10 @@ export function buildWriterSystemPrompt( ] : [ buildGenreIntro(book, genreProfile), - buildCoreRules(resolvedLengthSpec), buildGovernedInputContract("zh", governed), buildChapterMemoContract("zh", governed), buildLengthGuidance(resolvedLengthSpec, "zh"), - buildWritingCraftCard("zh"), - buildProseExecutionRules("zh"), - buildCreativeConstitution("zh"), - buildImmersionPillars("zh"), buildGoldenOpeningDiscipline(chapterNumber, "zh"), - buildGoldenChaptersRules(chapterNumber, isEnglish ? "en" : "zh"), bookRules?.enableFullCastTracking ? buildFullCastTracking() : "", buildGenreRules(genreProfile, genreBody), buildProtagonistRules(bookRules), @@ -190,339 +179,6 @@ function buildLengthGuidance(lengthSpec: LengthSpec, language: "zh" | "en"): str - 硬区间:${lengthSpec.hardMin}-${lengthSpec.hardMax}字`; } -// --------------------------------------------------------------------------- -// Core rules (~25 universal rules) -// --------------------------------------------------------------------------- - -function buildCoreRules(lengthSpec: LengthSpec): string { - return `## 核心规则 - -1. 以简体中文工作,句子长短交替,段落适合手机阅读(3-5行/段) -2. 目标字数:${lengthSpec.target}字,允许区间:${lengthSpec.softMin}-${lengthSpec.softMax}字 -3. 伏笔前后呼应,不留悬空线;所有埋下的伏笔都必须在后续收回 -4. 只读必要上下文,不机械重复已有内容 - -## 人物塑造铁律 - -- 人设一致性:角色行为必须由"过往经历 + 当前利益 + 性格底色"共同驱动,永不无故崩塌 -- 人物立体化:核心标签 + 反差细节 = 活人;十全十美的人设是失败的 -- 拒绝工具人:配角必须有独立动机和反击能力;主角的强大在于压服聪明人,而不是碾压傻子 -- 角色区分度:不同角色的说话语气、发怒方式、处事模式必须有显著差异 -- 情感/动机逻辑链:任何关系的改变(结盟、背叛、从属)都必须有铺垫和事件驱动 - -## 叙事技法 - -- Show, don't tell:用细节堆砌真实,用行动证明强大;角色的野心和价值观内化于行为,不通过口号喊出来 -- 五感代入法:场景描写中加入1-2种五感细节(视觉、听觉、嗅觉、触觉),增强画面感 -- 钩子设计:每章结尾设置悬念/伏笔/钩子,勾住读者继续阅读 -- 对话驱动:有角色互动的场景中,优先用对话传递冲突和信息,不要用大段叙述替代角色交锋。独处/逃生/探索场景除外 -- 信息分层植入:基础信息在行动中自然带出,关键设定结合剧情节点揭示,严禁大段灌输世界观 -- 描写必须服务叙事:环境描写烘托氛围或暗示情节,一笔带过即可;禁止无效描写 -- 日常/过渡段落必须为后续剧情服务:或埋伏笔,或推进关系,或建立反差。纯填充式日常是流水账的温床 - -## 看点密集度(硬尺) - -本章正文从头到尾必须满足以下节奏,写完后自检: - -- **每 300 字至少 1 个爽点**:小看点、有趣的梗、炸裂的小情节、反套路小动作、暧昧台词、情绪拉扯都算 -- **每 500 字至少 1 个钩子**:引发读者"接下来怎样"的小悬念;不要求揭开,要求抛出 -- **每 1000-1500 字至少 1 个完整悬念**:一组"问题—蓄力—未解"的结构,给读者追下去的理由 -- 不靠密度堆砌糊弄——单章里的爽点/钩子/悬念必须服务于本章 goal,不能是和主线无关的孤立段落 -- 如果某段连续 300 字以上是环境、回忆、议论、心理独白而没有推进主线或制造看点,就是水文,必须删或改 -- **密度是靠段落内的语义密度实现,不是靠把段落切碎**: - - 叙事段(非对话)**必须 ≥ 40 字**——差不多是手机屏 2 行,低于这个数就是"一句动作 / 一句观察 / 一句反应各自一段",直接违反移动端阅读节奏准则 - - 目标长度:叙事段 40-120 字(3-5 行手机屏),允许偶尔到 150 字讲一段连贯动作链 - - 对话段落不算入"短段"——它天然短,无需并段 - - **短段(<40 字)只在三个场景允许独立成段**:(1) 开场前 300 字里的反转金句(如"她突然跪下"),(2) 章末钩子最后一句(action-climax 定格),(3) 单章 ≤ 3 个"爆点短段"(一击命中、改变局势的关键台词、定格镜头) - - 三个场景合计一章最多 5 个短段,超过就是在"堆砌电报体" - - **连续短段硬规则**:不允许 3 个及以上短段(<40 字)并列连排。即使是上面三种合法场景里的短段,也不能连着甩。碰到"短段 → 短段"已经到极限,第 3 段必须是 ≥ 60 字的叙事段把动作 / 情绪 / 细节合回来,把读者呼吸节奏放回来。3 连短段 = reviewer 直接判"连续短段"警告 - - 审核硬阈值:narrative 段里 60% 以上 <40 字 → 段落过碎 / 连续 3+ 短段并排 → 连续短段。触发即返工 - - 正反例: - - ✗ "他转身。/ 看向门外。/ 门开了一条缝。/ 赵无尘站在光里。"(4 段全 <15 字,4 连短段) - - ✓ "他转身看向门外。门开了一条缝,赵无尘站在光里,手里还端着一碗凉透的茶。"(两段合并成 1 段 60 字,动作 + 观察 + 细节完整) - - ✗ "他一愣。/ 手停了。/ 嘴唇发白。"(3 连心理反应各自一段) - - ✓ "他一愣,手停了,嘴唇发白。"(并段为 1 句节奏紧凑的叙事) - -## 章节 80/20 断章(硬尺) - -- **永远不要在一章里把本章故事讲完**:本章的主剧情写到 80%,剩下 20% 留给下一章开头消化/揭示/后果 -- 章末必须断在 action-climax 的那一刻:主角刚放大招尚未见效 / 刚拔刀尚未落下 / 刚塞出银行卡尚未转身——不给结果,让读者到下一章才看到 -- 章节结构优先于字数:宁可超出目标字数几百字去完成一个完整的小高潮+断章,也不要为了卡字数切断节奏 -- 不要为了"凑 2000 字"硬加无关对话/描写;也不要为了"不超 2000 字"提前把高潮讲完 - -## 逻辑自洽 - -- 三连反问自检:每写一个情节,反问"他为什么要这么做?""这符合他的利益吗?""这符合他之前的人设吗?" -- 反派不能基于不可能知道的信息行动(信息越界检查) -- 关系改变必须事件驱动:如果主角要救人必须给出利益理由,如果反派要妥协必须是被抓住了死穴 -- 场景转换必须有过渡:禁止前一刻在A地、下一刻毫无过渡出现在B地 -- 每段至少带来一项新信息、态度变化或利益变化,避免空转 - -## 语言约束 - -- 句式多样化:长短句交替,严禁连续使用相同句式或相同主语开头 -- 词汇控制:多用动词和名词驱动画面,少用形容词;一句话中最多1-2个精准形容词 -- 群像反应不要一律"全场震惊",改写成1-2个具体角色的身体反应 -- 情绪用细节传达:✗"他感到非常愤怒" → ✓"他捏碎了手中的茶杯,滚烫的茶水流过指缝" -- 禁止元叙事(如"到这里算是钉死了"这类编剧旁白) - -## 去AI味铁律 - -- 【铁律】叙述者永远不得替读者下结论。读者能从行为推断的意图,叙述者不得直接说出。✗"他想看陆焚能不能活" → ✓只写踢水囊的动作,让读者自己判断 -- 【铁律】正文中严禁出现分析报告式语言:禁止"核心动机""信息边界""信息落差""核心风险""利益最大化""当前处境"等推理框架术语。人物内心独白必须口语化、直觉化。✗"核心风险不在今晚吵赢" → ✓"他心里转了一圈,知道今晚不是吵赢的问题" -- 【铁律】转折/惊讶标记词(仿佛、忽然、竟、竟然、猛地、猛然、不禁、宛如)全篇总数不超过每3000字1次。超出时改用具体动作或感官描写传递突然性 -- 【铁律】同一体感/意象禁止连续渲染超过两轮。第三次出现相同意象域(如"火在体内流动")时必须切换到新信息或新动作,避免原地打转 -- 【铁律】六步走心理分析是写作推导工具,其中的术语("当前处境""核心动机""信息边界""性格过滤"等)只用于PRE_WRITE_CHECK内部推理,绝不可出现在正文叙事中 -- 反例→正例速查:✗"虽然他很强,但是他还是输了"→✓"他确实强,可对面那个老东西更脏";✗"然而事情并没有那么简单"→✓"哪有那么便宜的事";✗"这一刻他终于明白了什么是力量"→✓删掉,让读者自己感受 - -## 硬性禁令 - -- 【硬性禁令】全文严禁出现"不是……而是……""不是……,是……""不是A,是B"句式,出现即判定违规。改用直述句 -- 【硬性禁令】全文严禁出现破折号"——",用逗号或句号断句 -- 正文中禁止出现hook_id/账本式数据(如"余量由X%降到Y%"),数值结算只放POST_SETTLEMENT`; -} - -// --------------------------------------------------------------------------- -// 去AI味正面范例(反例→正例对照表) -// --------------------------------------------------------------------------- - -function buildAntiAIExamples(): string { - return `## 去AI味:反例→正例对照 - -以下对照表展示AI常犯的"味道"问题和修正方法。正文必须贴近正例风格。 - -### 情绪描写 -| 反例(AI味) | 正例(人味) | 要点 | -|---|---|---| -| 他感到非常愤怒。 | 他捏碎了手中的茶杯,滚烫的茶水流过指缝,但他像没感觉一样。 | 用动作外化情绪 | -| 她心里很悲伤,眼泪流了下来。 | 她攥紧手机,指节发白,屏幕上的聊天记录模糊成一片。 | 用身体细节替代直白标签 | -| 他感到一阵恐惧。 | 他后背的汗毛竖了起来,脚底像踩在了冰上。 | 五感传递恐惧 | - -### 转折与衔接 -| 反例(AI味) | 正例(人味) | 要点 | -|---|---|---| -| 虽然他很强,但是他还是输了。 | 他确实强,可对面那个老东西更脏。 | 口语化转折,少用"虽然...但是" | -| 然而,事情并没有那么简单。 | 哪有那么便宜的事。 | "然而"换成角色内心吐槽 | -| 因此,他决定采取行动。 | 他站起来,把凳子踢到一边。 | 删掉因果连词,直接写动作 | - -### "了"字与助词控制 -| 反例(AI味) | 正例(人味) | 要点 | -|---|---|---| -| 他走了过去,拿了杯子,喝了一口水。 | 他走过去,端起杯子,灌了一口。 | 连续"了"字削弱节奏,保留最有力的一个 | -| 他看了看四周,发现了一个洞口。 | 他扫了一眼四周,墙根裂开一道缝。 | 两个"了"减为一个,"发现"换成具体画面 | - -### 词汇与句式 -| 反例(AI味) | 正例(人味) | 要点 | -|---|---|---| -| 那双眼睛充满了智慧和深邃。 | 那双眼睛像饿狼见了肉。 | 用具体比喻替代空洞形容词 | -| 他的内心充满了矛盾和挣扎。 | 他攥着拳头站了半天,最后骂了句脏话,转身走了。 | 内心活动外化为行动 | -| 全场为之震惊。 | 老陈的烟掉在了裤子上,烫得他跳起来。 | 群像反应具体到个人 | -| 不禁感叹道…… | (直接写感叹内容,删掉"不禁感叹") | 删除无意义的情绪中介词 | - -### 叙述者姿态 -| 反例(AI味) | 正例(人味) | 要点 | -|---|---|---| -| 这一刻,他终于明白了什么是真正的力量。 | (删掉这句——让读者自己从前文感受) | 不替读者下结论 | -| 显然,对方低估了他的实力。 | (只写对方的表情变化,让读者自己判断) | "显然"是作者在说教 | -| 他知道,这将是改变命运的一战。 | 他把刀从鞘里拔了一寸,又推回去。 | 用犹豫的动作暗示重要性 |`; -} - -// --------------------------------------------------------------------------- -// 六步走人物心理分析(新增方法论) -// --------------------------------------------------------------------------- - -function buildCharacterPsychologyMethod(): string { - return `## 六步走人物心理分析 - -每个重要角色在关键场景中的行为,必须经过以下六步推导: - -1. **当前处境**:角色此刻面临什么局面?手上有什么牌? -2. **核心动机**:角色最想要什么?最害怕什么? -3. **信息边界**:角色知道什么?不知道什么?对局势有什么误判? -4. **性格过滤**:同样的局面,这个角色的性格会怎么反应?(冲动/谨慎/阴险/果断) -5. **行为选择**:基于以上四点,角色会做出什么选择? -6. **情绪外化**:这个选择伴随什么情绪?用什么身体语言、表情、语气表达? - -禁止跳过步骤直接写行为。如果推导不出合理行为,说明前置铺垫不足,先补铺垫。 - -### 人设防崩三问(每次写角色行为前) -1. "他为什么要这么做?"——必须有利益或情感驱动 -2. "这符合他之前的人设吗?"——行为由"过往经历+当前利益+性格底色"共同驱动 -3. "如果把这段给一个只看过前面章节的读者,他会觉得突兀吗?"——人设一致性检验 - -### "盐溶于汤"原则 -主角的野心和价值观不能通过口号喊出来,必须内化于行为。 -- 反例:主角说"我要成为最强的人!" → 空洞口号 -- 正例:主角在别人放弃时默默多练了两个小时 → 用行动传达野心`; -} - -// --------------------------------------------------------------------------- -// 配角设计方法论 -// --------------------------------------------------------------------------- - -function buildSupportingCharacterMethod(): string { - return `## 配角设计方法论 - -### 配角B面原则 -配角必须有反击,有自己的算盘。主角的强大在于压服聪明人,而不是碾压傻子。 - -### 构建方法 -1. **动机绑定主线**:每个配角的行为动机必须与主线产生关联 - - 反派对抗主角不是因为"反派脸谱",而是有自己的诉求(如保护家人、争夺生存资源) - - 盟友帮助主角是因为有共同敌人或欠了人情,而非无条件忠诚 -2. **核心标签 + 反差细节**:让配角"活"过来 - - 表面冷硬的角色有不为人知的温柔一面(如偷偷照顾流浪动物) - - 看似粗犷的角色有出人意料的细腻爱好 - - 反派头子对老母亲言听计从 -3. **通过事件立人设**:禁止通过外貌描写和形容词堆砌来立人设,用角色在事件中的反应、选择、语气来展现性格 -4. **语言区分度**:不同角色的说话方式必须有辨识度——用词习惯、句子长短、口头禅、方言痕迹都是工具 -5. **拒绝集体反应**:群戏中不写"众人齐声惊呼",而是挑1-2个角色写具体反应`; -} - -// --------------------------------------------------------------------------- -// 读者心理学框架(新增方法论) -// --------------------------------------------------------------------------- - -function buildReaderPsychologyMethod(): string { - return `## 读者心理学框架 - -写作时同步考虑读者的心理状态: - -- **期待管理**:在读者期待释放时,适当延迟以增强快感;在读者即将失去耐心时,立即给反馈 -- **信息落差**:让读者比角色多知道一点(制造紧张),或比角色少知道一点(制造好奇) -- **情绪节拍**:压制→释放→更大的压制→更大的释放。释放时要超过读者心理预期。递进式升级——不是一次到位,而是层层加码(被骂→手机掉下水道→被噎住→有人敲门),每次比上一次更过分 -- **锚定效应**:先给读者一个参照(对手有多强/困难有多大),再展示主角的表现 -- **沉没成本**:读者已经投入的阅读时间是留存的关键,每章都要给出"继续读下去的理由" -- **代入感维护**:主角的困境必须让读者能共情,主角的选择必须让读者觉得"我也会这么做"`; -} - -// --------------------------------------------------------------------------- -// 情感节点设计方法论 -// --------------------------------------------------------------------------- - -function buildEmotionalPacingMethod(): string { - return `## 情感节点设计 - -关系发展(友情、爱情、从属)必须经过事件驱动的节点递进: - -1. **设计3-5个关键事件**:共同御敌、秘密分享、利益冲突、信任考验、牺牲/妥协 -2. **递进升温**:每个事件推进关系一个层级,禁止跨越式发展(初见即死忠、一面之缘即深情) -3. **情绪用场景传达**:环境烘托(暴雨中独坐)+ 微动作(攥拳指尖发白)替代直白抒情 -4. **情感与题材匹配**:末世侧重"共患难的信任"、悬疑侧重"试探与默契"、玄幻侧重"利益捆绑到真正认可" -5. **禁止标签化互动**:不可突然称兄道弟、莫名深情告白,每次称呼变化都需要事件支撑 - -### 强情绪升级法(避免流水账的核武器) -流水账的修法不是删掉日常,而是给日常加"料": -1. **加入前因后果**:下班回家→加上"催债电话刚打来"的前因→日常立刻有了紧迫感 -2. **情绪递进**:不是一个坏事,而是坏事接着坏事——被骂→赶不上公交→手机掉了→直播课结束了→包子把自己噎住了。每层比上一层更过分 -3. **日常必须为主线服务**:万物皆为"饵"。日常段落要么埋伏笔,要么推关系,要么建立反差。纯填充的日常是流水账的温床`; -} - -// --------------------------------------------------------------------------- -// 代入感具体技法 -// --------------------------------------------------------------------------- - -function buildImmersionTechniques(): string { - return `## 代入感技法 - -- **自然信息交代**:角色身份/外貌/背景通过行动和对话带出,禁止"资料卡式"直接罗列 -- **画面代入法**:开场先给画面(动作、环境、声音),再给信息,让读者"看到"而非"被告知" -- **共鸣锚点**:主角的困境必须有普遍性(被欺压、不公待遇、被低估),让读者觉得"这也是我" -- **欲望钩子**:每章至少让读者产生一个"接下来会怎样"的好奇心 -- **信息落差应用**:让读者比角色多知道一点(紧张感)或少知道一点(好奇心),动态切换 -- **具体化/可视化**:描写时具体到读者脑海能浮现的东西——不写"一个大城市",写"三环堵了四十分钟的出租车后座" -- **熟悉感**:接地气的场景自带代入感——医院走廊的消毒水味、深夜便利店的暖光、雨天公交站的积水 - -### 欲望驱动(网文核心) -网文本质是满足读者的欲望。两种欲望必须交替使用: -- **基础欲望**(被动):不劳而获、高人一等、权势地位、扬眉吐气——读者天然渴望的东西 -- **主动欲望**(期待感):作者刻意制造的"情绪缺口"——压制→读者期待释放→释放时超过预期 -- 关键:释放点必须超过读者的心理预期,只满足70%的期待等于失败`; -} - -// --------------------------------------------------------------------------- -// Writing Craft Card (v10: compact rules, replaces 9 full modules) -// Full methodology is in style_guide.md; this is the always-on reminder. -// --------------------------------------------------------------------------- - -function buildWritingCraftCard(language: "zh" | "en"): string { - if (language === "en") { - return `## Writing Craft Rules - -- **Emotion**: Externalize through action — never write "he felt angry", write "he crushed the teacup" -- **Salt in soup**: Values conveyed through behavior, not slogans -- **Supporting cast**: Every side character has their own agenda. Protagonist wins by outsmarting smart people, not crushing fools -- **Five senses**: Wet shirt sticking to the back, hospital disinfectant smell, rain puddles at the bus stop -- **Concrete**: Don't write "a big city" — write "the back seat of a taxi stuck in traffic for forty minutes" -- **Sentence craft**: Avoid "although...however" / "nevertheless" / excessive "was". Use character reactions instead of transition words -- **Desire engine**: Create emotional gaps → reader anticipates release → release MUST exceed expectations. 70% satisfaction = failure -- **Character check**: Before every character action ask: Why? Does it match their profile? Would the reader find it jarring? -- **Dialogue**: Different characters speak differently — vocabulary, sentence length, verbal tics, dialect traces -- **Forbidden**: Info-dump character introductions / introducing 3+ new characters at once / "everyone gasped in unison" -- **Escalation**: Bad things stack — each layer worse than the last. Not one setback, but setback → worse setback → even worse -- **Cycle awareness**: If currently in build-up phase, lay new obstacles and information; if climax phase, write payoff that exceeds expectations; if aftermath phase, write consequences — who lost what, who gained what, how relationships changed -- **Post-climax impact**: After a climax, never jump straight to new build-up. The next 1-2 chapters must show change: costs paid, status shifted, new normal established -- **Expectation management**: Delay release when the reader craves it (to amplify payoff); deliver feedback immediately when the reader is about to lose patience -- **Information boundary**: What does this character know? What don't they know? What are they wrong about? Characters must act only on information they possess`; - } - - return `## 写作铁律 - -- **情绪**:用动作外化,不写"他感到愤怒",写"他捏碎了茶杯,滚烫的茶水流过指缝" -- **盐溶于汤**:价值观通过行为传达,不喊口号 -- **配角**:有自己的算盘和反击,主角压服聪明人不是碾压傻子 -- **五感**:潮湿的短袖黏在后背上、医院消毒水的味、雨天公交站的积水 -- **具体化**:不写"大城市",写"三环堵了四十分钟的出租车后座" -- **句式**:少用"虽然但是/然而/因此/了",用角色内心吐槽替代转折词 -- **欲望驱动**:制造情绪缺口→读者期待释放→释放时超过预期。满足70%等于失败 -- **人设三问**:为什么这么做?符合人设吗?读者会觉得突兀吗? -- **对话**:不同角色说话方式不同——用词习惯、句子长短、口头禅、方言痕迹 -- **禁止**:资料卡式介绍角色 / 一次引入超3个新角色 / 众人齐声惊呼 -- **升级**:坏事叠坏事,每层比上一层过分——被骂→手机掉了→直播课结束了→包子噎住了 -- **小目标周期意识**:如果当前处于蓄压阶段,铺新阻力新信息;如果是爆发阶段,写兑现超预期;如果是后效阶段,写改变和代价 -- **高潮后影响**:爆发后不能直接跳到下一个蓄压。紧接着的 1-2 章必须写出改变——谁失去了什么、谁得到了什么、关系怎么变了 -- **期待管理**:读者期待释放时适当延迟以增强快感;读者即将失去耐心时立即给反馈 -- **信息边界**:角色此刻知道什么?不知道什么?对局势有什么误判?角色只能基于已掌握的信息行动`; -} - -// --------------------------------------------------------------------------- -// 创作宪法(14 条原则精华) — always-on prose; internalise, do not report back -// --------------------------------------------------------------------------- - -function buildCreativeConstitution(language: "zh" | "en"): string { - if (language === "en") { - return `## Creative Constitution - -These fourteen principles are your spine. Internalise them — never quote them, never list them, never narrate them. They tell you how to pick between two plausible next sentences. - -Show don't tell: stack real detail to make truth visible, never deliver feeling in a flat declarative line. Let values dissolve in action like salt in soup — conviction is proved by what a character does when nobody is watching. Every character act sits on three legs at once: lived history, current interest, temperamental core; remove any leg and the act reads as authorial fiat. Every side character keeps their own ledger with their own profit motive; they exist before the protagonist meets them and continue after. Rhythm breathes — slow fires cook the richest broth, daily moments work as bait for the main line, they are never filler. End every chapter with a small hook or emotional gap; readers must want the next page. Everyone on stage stays smart — no convenient stupidity, saint-mode mercy, or un-set-up compromise. Use after-time references in the voice of the era they land in. Timeline and period common sense cannot be bent. Seventy percent of daily scenes must double as seeds for the main line later. Relationship changes need an event to drive them — no overnight brotherhood, no out-of-nowhere love. Character setup holds across the arc; growth shows its work. Important plot beats and foreshadowing earn their detail — scene over summary. Refuse chronicle drift: every line either moves the plot or sharpens a person.`; - } - return `## 创作宪法 - -这十四条原则是你写作的脊梁。内化它们——绝不引用、绝不列表、绝不在正文里复述。它们的用途是帮你在"两个都说得通的下一句"之间做出选择。 - -Show don't tell,用细节堆出真实,禁止用一行直白陈述替代情绪。价值观要像盐溶于汤——角色的信念靠"没人看时他在做什么"来证明,不靠口号。任何角色的任何行动都必须同时立于三条腿上:过往经历、当前利益、性格底色;缺一条就成了作者强行安排。每个配角都有自己的账本和利益诉求,他们在遇到主角之前就存在、在离开主角之后继续过日子,不是工具人。节奏即呼吸——慢火才能炖出高汤,日常当饵用,不是填充。每章结尾必须有小悬念或情绪缺口,把读者钉在下一章。全员智商在线——禁止降智、圣母心、无铺垫的妥协。后世梗用符合年代语境的说法落地。时间线与时代常识不能错。日常场景的七成必须在后面成为主线伏笔。任何关系的改变都要事件驱动——没有一夜称兄道弟、没有莫名其妙的深情。人设前后一致,成长有过程。重要剧情和伏笔用场景,不用总结。拒绝流水账——每一行字要么推动剧情,要么塑造人物。`; -} - -// --------------------------------------------------------------------------- -// 代入感六支柱 — always-on prose; internalise, do not narrate checklist items -// --------------------------------------------------------------------------- - -function buildImmersionPillars(language: "zh" | "en"): string { - if (language === "en") { - return `## Six Pillars of Immersion - -Reader immersion rests on six pillars. Write to install all six inside the first few pages of every scene — tacitly, without ever addressing them by name. - -Tag the basics: within a hundred words the reader knows who is on stage, where the stage is, and what is happening, so they can build the room in their head. Reach for visible familiarity: give ground-level specifics the reader has touched in their own life, so the scene loads before the second paragraph ends. Earn resonance twice — cognitive (the reader would make the same choice) and emotional (family feeling, anger at unfair treatment, grief, quiet pride). Feed desire on two tracks: the base wants (getting something for nothing, outranking those above, exhaling after being pressed down) and the active want the chapter seeds itself — an expectation gap the reader now carries forward. Plant sensory hooks: every scene carries one or two senses beyond sight (sound, smell, touch, taste), dropped in passing, never a paragraph of weather. Make characters alive with a core tag plus one contrasting detail — the cold killer who feeds stray cats, the warm father whose jokes land like knives. These pillars are the default shape of every scene, not a checklist you tick at the end.`; - } - return `## 代入感六支柱 - -读者代入感靠六根支柱支撑。每一个场景的前几页都要把六根柱子立起来——静默地立,不要点名、不要报告。 - -基础信息标签化:一百字内让读者知道谁在场、在哪儿、发生什么,读者脑里才能搭出这个房间。可视化熟悉感:给出读者亲身碰过的地面级具体细节——医院消毒水的味、地铁座椅的凉、外卖塑料袋的塑胶感——场景在第二段之前就要加载完。共鸣分两层:认知共鸣("这种情况下我也会这么选")+ 情绪共鸣(亲情、被欺压时的愤怒、不公、隐忍的骄傲)。欲望两条腿走路:基础欲望(不劳而获、压制比自己高的人、被欺压之后的扬眉吐气)+ 主动欲望(本章自己挖的期待感——一个读者会带到下一章的情绪缺口)。五感钩子:每个场景除视觉外放 1-2 种感官细节(听/嗅/触/味),顺手带过,绝不写成大段天气描写。人设要"核心标签 + 一个反差细节"才活——冷面杀手偷偷喂流浪猫、和善父亲开的玩笑像刀子。这六根柱子是场景的默认形状,不是章末打勾的清单。`; -} - // --------------------------------------------------------------------------- // 黄金三章 prose discipline — Phase 6.5 // Single conditional append (chapterNumber <= 3). No new schema, no new @@ -550,89 +206,6 @@ The discipline that runs across all three opening chapters: paragraphs of three 贯穿开篇三章的纪律:段落 3-5 行(手机阅读节奏),动词压过形容词,每一章结尾必有小钩子——小悬念、未解之问、情绪缺口。**本章场景 ≤ 2 个、有名有姓参与正面冲突的人物 ≤ 2 个(主角 + 1 个触发者或对手;路人甲乙只报身份不给名字,不展开)。开篇人物上限从 3 收紧到 2:3 个已经够读者记混,2 个最稳。** 信息分层植入到动作里:基础信息(外貌、身份、处境)通过主角行动自然带出;关键设定(系统规则、世界底层)结合剧情节点揭示;禁止整段 exposition。`; } -// --------------------------------------------------------------------------- -// 黄金开篇(中文3章/英文5章) -// --------------------------------------------------------------------------- - -function buildGoldenChaptersRules(chapterNumber?: number, language?: string): string { - const isEnglish = language === "en"; - const goldenLimit = isEnglish ? 5 : 3; - if (chapterNumber === undefined || chapterNumber > goldenLimit) return ""; - - const zhRules: Record = { - 1: `### 第一章:抛出核心冲突 -- 开篇直接进入冲突场景,禁止用背景介绍/世界观设定开头 -- 第一段必须有动作或对话,让读者"看到"画面 -- **手机屏第一页(正文约前 300 字)的最后一句必须是戏剧性反转/反差句**,不是铺垫——警察叔叔我穿越了、我大概明天就要死了、我躺在自己的葬礼上、妻子和婆婆同时掉水里了,类似这种一句话的钩子 -- **开篇场景限制:最多 1-2 个场景,有名有姓参与正面冲突的人物上限 2 个(主角 + 1 个触发者/对手)**;路人甲乙只给身份标签("穿红衣的女人""跛脚老头")不给名字 -- 主角身份/外貌/背景通过行动自然带出,禁止资料卡式罗列 -- 本章结束前,核心矛盾必须浮出水面 -- 一句对话能交代的信息不要用一段叙述,角色身份、性格、地位都可以从一句有特色的台词中带出`, - 2: `### 第二章:展现金手指/核心能力 -- 主角的核心优势(金手指/特殊能力/信息差等)必须在本章初现 -- 金手指的展现必须通过具体事件,不能只是内心独白"我获得了XX" -- 开始建立"主角有什么不同"的读者认知 -- 第一个小爽点应在本章出现 -- 继续收紧核心冲突,不引入新支线`, - 3: `### 第三章:明确短期目标 -- 主角的第一个阶段性目标必须在本章确立 -- 目标必须具体可衡量(打败某人/获得某物/到达某处),不能是抽象的"变强" -- 读完本章,读者应能说出"接下来主角要干什么" -- 章尾钩子要足够强,这是读者决定是否继续追读的关键章`, - }; - - const enRules: Record = { - 1: `### Chapter 1: Drop into conflict -- Open with action or dialogue — no worldbuilding preamble -- First paragraph must show a scene, not tell backstory -- **The last sentence of the first 300 words (first phone screen) must be a dramatic reversal / striking beat** — "Officer, I transmigrated"-level, "I'll probably die tomorrow"-level — not scene-setting -- **Max 1-2 locations; max 2 named characters who actually clash in the chapter (protagonist + one trigger/opponent)**. Walk-ons get a role tag ("the woman in red", "the limping old man"), no name -- Protagonist identity revealed through behavior, not info-dump -- Core conflict must surface before chapter end`, - 2: `### Chapter 2: Reveal the edge -- The protagonist's unique advantage (power/secret/skill) must appear -- Show it through a concrete event, not internal monologue ("I gained X") -- First small payoff/satisfaction beat should land here -- Tighten the core conflict, don't open new subplots`, - 3: `### Chapter 3: Lock in the short-term goal -- A specific, measurable goal must be established (defeat someone / obtain something / reach somewhere) -- Reader must be able to say "I know what the protagonist wants next" -- End with a strong hook — this is the make-or-break chapter for retention`, - 4: `### Chapter 4: First major payoff -- Deliver the first BIG satisfaction beat — reader has invested 3 chapters, reward them -- Protagonist uses their edge to achieve something meaningful (not just survive) -- Raise the emotional stakes: what the protagonist stands to LOSE becomes clear -- Introduce or deepen a relationship that matters (ally, rival, love interest)`, - 5: `### Chapter 5: Raise the stakes before paywall -- New threat or complication that makes the goal harder (new antagonist, betrayal, revelation) -- The world expands: reader sees there's a bigger game beyond the initial conflict -- End on the strongest cliffhanger yet — reader hits paywall after this chapter -- They must feel "I CANNOT stop here" — this is the conversion chapter`, - }; - - const rules = isEnglish ? enRules : zhRules; - const header = isEnglish - ? `## Golden ${goldenLimit} Chapters — Chapter ${chapterNumber} - -The opening ${goldenLimit} chapters determine whether readers stay or leave. Before the paywall (ch6-8), every chapter must hook harder than the last. - -- Start from an explosion, not the first brick -- No info-dumps: worldbuilding reveals through action -- Each chapter: 1 storyline; **ch1-ch2 keep named characters in conflict ≤ 2** (protagonist + one), ch3+ relax to ≤ 3 -- Lead with strong emotion: injustice, danger, mystery, desire` - : `## 黄金${goldenLimit}章特殊指令(当前第${chapterNumber}章) - -开篇${goldenLimit}章决定读者是否追读。遵循以下强制规则: - -- 开篇不要从第一块砖头开始砌楼——从炸了一栋楼开始写 -- 禁止信息轰炸:世界观、力量体系等设定随剧情自然揭示 -- 每章聚焦 1 条故事线;**第 1-2 章有名有姓参与正面冲突的人物 ≤ 2 个(主角 + 1 个触发者/对手),第 3 章起可放宽到 ≤ 3 个** -- 强情绪优先:利用读者共情(亲情纽带、不公待遇、被低估)快速建立代入感`; - - return `${header} - -${rules[chapterNumber] ?? ""}`; -} // --------------------------------------------------------------------------- // Full cast tracking (conditional) @@ -693,27 +266,6 @@ function buildNarrativePersonRule(bookRules: BookRules | null, language: "zh" | : "## 叙事人称(硬约束)\n本书使用第三人称叙述。"; } -/** - * Cross-theme failure modes surfaced by results-oriented testing across genres: - * - simile over-reliance (~3 "像/仿佛/如同" per 1000 chars regardless of theme) - * - high-density dramatic beats summarized instead of dramatized when the - * chapter is tight (climaxes told, not shown). - * Theme-independent, so this lives in the always-on writer discipline. - */ -function buildProseExecutionRules(language: "zh" | "en"): string { - if (language === "en") { - return `## Prose execution (cross-theme failure modes) - -**Simile restraint.** Do not lean on "like / as if / as though" as a default device. At most one simile per scene, and only when it lights the image up better than plain rendering would. Priority is always: a precise verb > a concrete action or sensory detail > direct description > simile. Before reaching for "like…", check whether an exact verb or a concrete action would hit harder. - -**Play out the climax — never summarize it.** This chapter's high-density / high-stakes beats — a conflict erupting, life-or-death, a major turn, a reveal, an action climax — MUST be played out beat by beat (action, dialogue, the senses, pauses, pacing). Never compress them into "then he saved them, the police came, the antagonist was arrested." When a chapter packs several major events, expand the single most important one into a full scene; connective tissue may be compressed, but the key beat must never decay into a summary. The tighter the chapter, the harder this holds — if you are short on words, pack fewer events, do not render the climax as a synopsis.`; - } - return `## 文笔执行(跨题材通病纠正) - -**明喻节制。** 不要把"像/仿佛/如同/像……一样"当默认修辞反复用。每个场景明喻最多 1 处,且只在它真能点亮画面、比直写更准时才用。优先级永远是:精确的动词 > 具体的动作或感官细节 > 直接描写 > 明喻。想写"像……"之前,先问一句:换成一个准确的动词或一个具体动作,是不是更狠。 - -**高潮必须演出、不许概述。** 本章的高密度/高风险节拍——冲突爆发、生死、重大转折、真相揭露、动作高潮——必须一拍一拍现场演出(动作、对话、五感、停顿、节奏),绝不能用一两句"然后他救了人、警察来了、对手被捕"带过。当一章里挤了多个重大事件时,挑最关键的那一拍写成完整场景,次要的可压成过渡,但最关键那拍永远不许退化成总结。章节越紧凑越要守这条——字数不够就少塞事件,而不是把高潮写成梗概。`; -} function buildProtagonistRules(bookRules: BookRules | null): string { if (!bookRules?.protagonist) return ""; @@ -776,39 +328,6 @@ function buildStyleFingerprint(fingerprint?: string): string { ${fingerprint}`; } -// --------------------------------------------------------------------------- -// Pre-write checklist -// --------------------------------------------------------------------------- - -function buildPreWriteChecklist(book: BookConfig, gp: GenreProfile): string { - let idx = 1; - const lines = [ - "## 动笔前必须自问", - "", - `${idx++}. 【大纲锚定】本章对应卷纲中的哪个节点/阶段?本章必须推进该节点的剧情,不得跳过或提前消耗后续节点。如果卷纲指定了章节范围,严格遵守节奏。`, - `${idx++}. 主角此刻利益最大化的选择是什么?`, - `${idx++}. 这场冲突是谁先动手,为什么非做不可?`, - `${idx++}. 配角/反派是否有明确诉求、恐惧和反制?行为是否由"过往经历+当前利益+性格底色"驱动?`, - `${idx++}. 反派当前掌握了哪些已知信息?哪些信息只有读者知道?有无信息越界?`, - `${idx++}. 章尾是否留了钩子(悬念/伏笔/冲突升级)?`, - ]; - - if (gp.numericalSystem) { - lines.push(`${idx++}. 本章收益能否落到具体资源、数值增量、地位变化或已回收伏笔?`); - } - - // 17雷点精华预防 - lines.push( - `${idx++}. 【流水账检查】本章是否有无冲突的日常流水叙述?如有,加入前因后果或强情绪改造`, - `${idx++}. 【主线偏离检查】本章是否推进了主线目标?支线是否在2-3章内与核心目标关联?`, - `${idx++}. 【爽点节奏检查】最近3-5章内是否有小爽点落地?读者的"情绪缺口"是否在积累或释放?`, - `${idx++}. 【人设崩塌检查】角色行为是否与已建立的性格标签一致?有无无铺垫的突然转变?`, - `${idx++}. 【视角检查】本章视角是否清晰?同场景内说话人物是否控制在3人以内?`, - `${idx++}. 如果任何问题答不上来,先补逻辑链,再写正文`, - ); - - return lines.join("\n"); -} // --------------------------------------------------------------------------- // Creative-only output format (no settlement blocks) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 65691286..2398524a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -252,9 +252,6 @@ export { normalizeSkillIdList, normalizeRequestedIntent, normalizePlayMode, - isExplicitWriteChapterCommand, - isUsablePlayInitialScene, - isWriteNextInstruction, } from "./interaction/action-envelope.js"; export { ExecutionStatusSchema, diff --git a/packages/core/src/interaction/action-envelope.ts b/packages/core/src/interaction/action-envelope.ts index 172db4bd..f63cdd9e 100644 --- a/packages/core/src/interaction/action-envelope.ts +++ b/packages/core/src/interaction/action-envelope.ts @@ -228,36 +228,3 @@ export function normalizePlayMode(value: unknown): PlayMode | undefined { if (value === undefined || value === null || value === "") return undefined; return PlayModeSchema.parse(value); } - -const INCOMPLETE_PLAY_SCENE_SUFFIX = - /(?:叫|是|为|在|向|把|将|和|与|或|但|却|因为|如果|当|等|、|,|:|;|——|“|‘|《|(|\()$/u; - -export function isUsablePlayInitialScene(value: string | undefined): boolean { - const text = value?.trim(); - if (!text) return false; - if (text.length < 12) return false; - if (INCOMPLETE_PLAY_SCENE_SUFFIX.test(text)) return false; - return true; -} - -export function isWriteNextInstruction( - instruction: string, - options: { readonly allowSlashWrite?: boolean } = {}, -): boolean { - const trimmed = instruction.trim(); - const pattern = options.allowSlashWrite - ? /^(\/write|continue|继续|继续写|写下一章|write next|下一章|再来一章)$/i - : /^(continue|继续|继续写|写下一章|write next|下一章|再来一章)$/i; - return pattern.test(trimmed); -} - -export function isExplicitWriteChapterCommand(instruction: string): boolean { - const trimmed = instruction.trim(); - if (!trimmed) return false; - - const zhWriteChapter = - /^(?:请|帮我|麻烦|现在|直接|开始|继续|接着|再)?\s*(?:写|续写|创作|生成)(?:出|一下)?\s*(?:第?\s*一\s*章|第?\s*1\s*章|下一章|一章|正文|章节)(?:\s|[,。,.!!??;;::]|$)/.test(trimmed); - if (zhWriteChapter) return true; - - return /^(?:please\s+)?(?:write|continue|draft|generate)\s+(?:(?:the\s+)?next\s+chapter|chapter(?:\s+(?:1|one))?)\b/i.test(trimmed); -} diff --git a/packages/core/src/interaction/runtime.ts b/packages/core/src/interaction/runtime.ts index af6fb8e5..63715b42 100644 --- a/packages/core/src/interaction/runtime.ts +++ b/packages/core/src/interaction/runtime.ts @@ -1089,7 +1089,6 @@ export async function runInteractionRequest(params: { } case "chat": { const bookId = request.bookId ?? session.activeBookId; - const prompt = request.instruction?.trim().toLowerCase() ?? ""; const toolResult = params.tools.chat ? await params.tools.chat(request.instruction ?? "", { bookId, @@ -1097,27 +1096,15 @@ export async function runInteractionRequest(params: { }) : undefined; const metadata = extractToolMetadata(toolResult); - const responseText = metadata.responseText ?? ( - /^(hi|hello|hey|你好|嗨|哈喽)$/i.test(prompt) - ? (bookId - ? localize(language, { - zh: `你好。当前作品是 ${bookId}。你可以让我继续写、修订章节,或者解释当前卡住的原因。`, - en: `Hi. Active book is ${bookId}. Ask me to continue, revise a chapter, or explain what is blocked.`, - }) - : localize(language, { - zh: "你好。当前还没有激活作品。你可以先打开作品、列出作品,或者直接告诉我你要写什么。", - en: "Hi. No active book yet. Open a book, list books, or tell me what you want to write.", - })) - : (bookId - ? localize(language, { - zh: `我在。当前作品是 ${bookId}。你可以让我继续写、修订章节、重写、调整焦点,或者查看流水线为何停止。`, - en: `I’m here. Active book is ${bookId}. You can ask me to continue, revise a chapter, rewrite, change focus, or inspect why the pipeline stopped.`, - }) - : localize(language, { - zh: "我在。当前还没有绑定作品。先打开作品、列出作品,或者直接描述你要写什么。", - en: "I’m here. No active book is bound yet. Open a book, list books, or describe what you want to write.", - })) - ); + const responseText = metadata.responseText ?? (bookId + ? localize(language, { + zh: `我在。当前作品是 ${bookId}。你可以让我继续写、修订章节、重写、调整焦点,或者查看流水线为何停止。`, + en: `I’m here. Active book is ${bookId}. You can ask me to continue, revise a chapter, rewrite, change focus, or inspect why the pipeline stopped.`, + }) + : localize(language, { + zh: "我在。当前还没有绑定作品。先打开作品、列出作品,或者直接描述你要写什么。", + en: "I’m here. No active book is bound yet. Open a book, list books, or describe what you want to write.", + })); const completed = markCompleted(session); return { session: addEvent(completed, "task.completed", "completed", responseText), diff --git a/packages/core/src/materials/retrieve.ts b/packages/core/src/materials/retrieve.ts index d2655ea3..c692cc16 100644 --- a/packages/core/src/materials/retrieve.ts +++ b/packages/core/src/materials/retrieve.ts @@ -3,6 +3,11 @@ import { join } from "node:path"; import { safeChildPath } from "../utils/path-safety.js"; import { toPosixPath } from "../utils/posix-path.js"; import type { MaterialAsset, MaterialPurpose } from "./ingest.js"; +import { + LocalSearchIndex, + splitMarkdownForSearch, + type SearchDocument, +} from "../retrieval/local-search.js"; export interface RetrieveMaterialsInput { readonly query: string; @@ -25,17 +30,15 @@ export interface RetrievedMaterial { const DEFAULT_LIMIT = 5; const MAX_LIMIT = 12; -const SNIPPET_RADIUS = 700; +const MATERIAL_SCOPE = "archived-materials"; export async function retrieveMaterials( projectRoot: string, input: RetrieveMaterialsInput, ): Promise { - const queryTerms = extractTerms(input.query); const assets = await listMaterialAssets(projectRoot); - const results: RetrievedMaterial[] = []; + const documents: SearchDocument[] = []; for (const asset of assets) { - if (input.purpose && asset.purpose !== input.purpose) continue; const markdownPath = safeChildPath(projectRoot, asset.markdownPath); let markdown = ""; try { @@ -43,26 +46,47 @@ export async function retrieveMaterials( } catch { continue; } - const score = scoreMaterial(asset, markdown, queryTerms); - if (queryTerms.length > 0 && score <= 0) continue; - const snippet = buildSnippet(markdown, queryTerms); - results.push({ - id: asset.id, - title: asset.title, - kind: asset.kind, - purpose: asset.purpose, - source: asset.source, - // Manifests written by older Windows builds may contain "\" separators. - markdownPath: toPosixPath(asset.markdownPath), - score, - excerpt: snippet.excerpt, - charStart: snippet.charStart, - charEnd: snippet.charEnd, + const normalizedPath = toPosixPath(asset.markdownPath); + splitMarkdownForSearch(markdown).forEach((segment, index) => { + documents.push({ + id: `material:${asset.id}:${index}`, + scope: MATERIAL_SCOPE, + kind: `material:${asset.purpose}`, + source: `${normalizedPath}:${segment.charStart}-${segment.charEnd}`, + title: [asset.title, segment.heading].filter(Boolean).join(" · "), + body: segment.body, + metadata: { + assetId: asset.id, + assetTitle: asset.title, + assetKind: asset.kind, + purpose: asset.purpose, + source: asset.source, + markdownPath: normalizedPath, + charStart: segment.charStart, + charEnd: segment.charEnd, + }, + }); }); } - return results - .sort((a, b) => b.score - a.score || a.title.localeCompare(b.title)) - .slice(0, normalizeLimit(input.limit)); + + const searchIndex = new LocalSearchIndex(join(projectRoot, ".inkos", "retrieval.db")); + try { + searchIndex.replaceScope(MATERIAL_SCOPE, documents); + const limit = normalizeLimit(input.limit); + const ranked = searchIndex.search(input.query, { + scope: MATERIAL_SCOPE, + kinds: input.purpose ? [`material:${input.purpose}`] : undefined, + limit: Math.min(MAX_LIMIT * 4, limit * 4), + }).map((hit) => materialFromHit(hit.metadata, hit.score, hit.body)); + const seen = new Set(); + return ranked.filter((result) => { + if (seen.has(result.id)) return false; + seen.add(result.id); + return true; + }).slice(0, limit); + } finally { + searchIndex.close(); + } } async function listMaterialAssets(projectRoot: string): Promise { @@ -87,50 +111,27 @@ async function listMaterialAssets(projectRoot: string): Promise return assets; } -function scoreMaterial(asset: MaterialAsset, markdown: string, terms: readonly string[]): number { - if (terms.length === 0) return 1; - const title = asset.title.toLowerCase(); - const source = asset.source.toLowerCase(); - const body = markdown.toLowerCase(); - let score = 0; - for (const term of terms) { - const normalized = term.toLowerCase(); - if (title.includes(normalized)) score += 8; - if (source.includes(normalized)) score += 4; - const first = body.indexOf(normalized); - if (first >= 0) score += 2 + Math.max(0, 2 - first / 4000); - } - return score; -} - -function buildSnippet(markdown: string, terms: readonly string[]): { excerpt: string; charStart: number; charEnd: number } { - const lower = markdown.toLowerCase(); - let hit = -1; - for (const term of terms) { - const idx = lower.indexOf(term.toLowerCase()); - if (idx >= 0 && (hit < 0 || idx < hit)) hit = idx; - } - const center = hit >= 0 ? hit : Math.min(markdown.length, 500); - const charStart = Math.max(0, center - SNIPPET_RADIUS); - const charEnd = Math.min(markdown.length, center + SNIPPET_RADIUS); - return { - excerpt: markdown.slice(charStart, charEnd).trim(), - charStart, - charEnd, - }; -} - function normalizeLimit(limit: number | undefined): number { if (!Number.isFinite(limit ?? DEFAULT_LIMIT)) return DEFAULT_LIMIT; return Math.max(1, Math.min(MAX_LIMIT, Math.floor(limit ?? DEFAULT_LIMIT))); } -function extractTerms(query: string): string[] { - const raw = query.normalize("NFKC").trim().toLowerCase(); - if (!raw) return []; - const terms = new Set(); - for (const match of raw.matchAll(/[\p{L}\p{N}]{2,}/gu)) { - terms.add(match[0]); - } - return [...terms].slice(0, 24); +function materialFromHit( + metadata: Readonly> | undefined, + score: number, + excerpt: string, +): RetrievedMaterial { + const value = metadata ?? {}; + return { + id: String(value.assetId ?? ""), + title: String(value.assetTitle ?? ""), + kind: String(value.assetKind ?? "text") as MaterialAsset["kind"], + purpose: String(value.purpose ?? "general") as MaterialPurpose, + source: String(value.source ?? ""), + markdownPath: String(value.markdownPath ?? ""), + score, + excerpt, + charStart: Number(value.charStart ?? 0), + charEnd: Number(value.charEnd ?? excerpt.length), + }; } diff --git a/packages/core/src/models/input-governance.ts b/packages/core/src/models/input-governance.ts index a2981468..d3011b34 100644 --- a/packages/core/src/models/input-governance.ts +++ b/packages/core/src/models/input-governance.ts @@ -118,6 +118,17 @@ export const ChapterTraceSchema = z.object({ compressibleTokens: z.number().int().nonnegative().default(0), budgetTokens: z.number().int().nonnegative().default(0), }).optional(), + retrieval: z.object({ + engine: z.literal("sqlite-fts5-bm25"), + query: z.string(), + candidates: z.array(z.object({ + id: z.string(), + kind: z.string(), + source: z.string(), + score: z.number(), + })), + semanticSelectedIds: z.array(z.string()).optional(), + }).optional(), notes: z.array(z.string()).default([]), }); diff --git a/packages/core/src/pipeline/runner.ts b/packages/core/src/pipeline/runner.ts index ecfae219..7278ac0f 100644 --- a/packages/core/src/pipeline/runner.ts +++ b/packages/core/src/pipeline/runner.ts @@ -30,7 +30,7 @@ import type { AgentContext } from "../agents/base.js"; import type { AuditResult, AuditIssue } from "../agents/continuity.js"; import type { RadarResult } from "../agents/radar.js"; import type { LengthSpec, LengthTelemetry } from "../models/length-governance.js"; -import type { ChapterMemo, ContextPackage, RuleStack } from "../models/input-governance.js"; +import type { ChapterMemo, ChapterTrace, ContextPackage, RuleStack } from "../models/input-governance.js"; import type { ContextCompressionCallback } from "../models/context-compression.js"; import { buildLengthSpec, countChapterLength, formatLengthCount, isOutsideHardRange, resolveLengthCountingMode, type LengthLanguage } from "../utils/length-metrics.js"; import { analyzeLongSpanFatigue } from "../utils/long-span-fatigue.js"; @@ -55,6 +55,7 @@ import { runChapterReviewCycle } from "./chapter-review-cycle.js"; 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"; const SEQUENCE_LEVEL_CATEGORIES = new Set([ "Pacing Monotony", "节奏单调", @@ -310,6 +311,16 @@ export interface TokenUsageSummary { readonly totalTokens: number; } +export interface ChapterContextTraceSummary { + readonly tracePath: string; + readonly selectedSources: ReadonlyArray; + readonly protectedSources: ReadonlyArray; + readonly compressibleSources: ReadonlyArray; + readonly tokenBudget: ChapterTrace["tokenBudget"]; + readonly retrieval?: ChapterTrace["retrieval"]; + readonly compression?: ChapterTrace["compression"]; +} + export interface ChapterPipelineResult { readonly chapterNumber: number; readonly title: string; @@ -320,11 +331,13 @@ export interface ChapterPipelineResult { readonly lengthWarnings?: ReadonlyArray; readonly lengthTelemetry?: LengthTelemetry; readonly tokenUsage?: TokenUsageSummary; + readonly contextTrace?: ChapterContextTraceSummary; } export interface WriteChaptersOptions { readonly wordCount?: number; readonly temperatureOverride?: number; + readonly externalContext?: string; readonly onChapterComplete?: ( result: ChapterPipelineResult, completedCount: number, @@ -442,8 +455,10 @@ export class PipelineRunner { private readonly state: StateManager; private readonly config: PipelineConfig; private readonly agentClients = new Map(); - private readonly operationContext = new AsyncLocalStorage<{ readonly signal?: AbortSignal }>(); - private memoryIndexFallbackWarned = false; + private readonly operationContext = new AsyncLocalStorage<{ + readonly signal?: AbortSignal; + readonly activatedSkills?: ReadonlyArray; + }>(); constructor(config: PipelineConfig) { this.config = config; @@ -454,9 +469,24 @@ export class PipelineRunner { signal: AbortSignal | undefined, task: () => Promise, ): Promise { - signal?.throwIfAborted(); - return this.operationContext.run({ signal }, async () => { - signal?.throwIfAborted(); + return this.runWithAgentContext({ signal }, task); + } + + async runWithAgentContext( + context: { + readonly signal?: AbortSignal; + readonly activatedSkills?: ReadonlyArray; + }, + task: () => Promise, + ): Promise { + const current = this.operationContext.getStore(); + const merged = { + signal: context.signal ?? current?.signal, + activatedSkills: context.activatedSkills ?? current?.activatedSkills, + }; + merged.signal?.throwIfAborted(); + return this.operationContext.run(merged, async () => { + merged.signal?.throwIfAborted(); return task(); }); } @@ -465,6 +495,10 @@ export class PipelineRunner { return this.operationContext.getStore()?.signal; } + private currentActivatedSkills(): ReadonlyArray | undefined { + return this.operationContext.getStore()?.activatedSkills; + } + private throwIfOperationAborted(): void { this.currentAbortSignal()?.throwIfAborted(); } @@ -702,6 +736,7 @@ export class PipelineRunner { logger: this.config.logger?.child(agent), onStreamProgress: this.config.onStreamProgress, signal: this.currentAbortSignal(), + activatedSkills: this.currentActivatedSkills(), }; } @@ -1728,11 +1763,21 @@ export class PipelineRunner { // Full pipeline (convenience — runs draft + audit + revise in one shot) // --------------------------------------------------------------------------- - async writeNextChapter(bookId: string, wordCount?: number, temperatureOverride?: number): Promise { + async writeNextChapter( + bookId: string, + wordCount?: number, + temperatureOverride?: number, + externalContext?: string, + ): Promise { this.throwIfOperationAborted(); const releaseLock = await this.state.acquireBookLock(bookId); try { - return await this._writeNextChapterLocked(bookId, wordCount, temperatureOverride, this.config.externalContext); + return await this._writeNextChapterLocked( + bookId, + wordCount, + temperatureOverride, + externalContext ?? this.config.externalContext, + ); } finally { await releaseLock(); } @@ -1757,7 +1802,7 @@ export class PipelineRunner { bookId, options.wordCount, options.temperatureOverride, - this.config.externalContext, + options.externalContext ?? this.config.externalContext, ); results.push(result); options.onChapterComplete?.(result, results.length, chapterCount); @@ -2190,6 +2235,7 @@ export class PipelineRunner { lengthWarnings, lengthTelemetry, tokenUsage: totalUsage, + ...(writeInput.contextTrace ? { contextTrace: writeInput.contextTrace } : {}), }; } @@ -3061,7 +3107,9 @@ ${matrix}`, bookDir: string, chapterNumber: number, externalContext?: string, - ): Promise> { + ): Promise & { + readonly contextTrace?: ChapterContextTraceSummary; + }> { if ((this.config.inputGovernanceMode ?? "v2") === "legacy") { return { externalContext }; } @@ -3081,6 +3129,29 @@ ${matrix}`, chapterIntentData: plan.intent, contextPackage: composed.contextPackage, ruleStack: composed.ruleStack, + contextTrace: { + tracePath: relativeToBookDir(bookDir, composed.tracePath), + selectedSources: [...composed.trace.selectedSources], + protectedSources: [...composed.trace.contextTiers.protectedSources], + compressibleSources: [...composed.trace.contextTiers.compressibleSources], + tokenBudget: { ...composed.trace.tokenBudget }, + ...(composed.trace.retrieval ? { + retrieval: { + ...composed.trace.retrieval, + candidates: composed.trace.retrieval.candidates.map((candidate) => ({ ...candidate })), + ...(composed.trace.retrieval.semanticSelectedIds + ? { semanticSelectedIds: [...composed.trace.retrieval.semanticSelectedIds] } + : {}), + }, + } : {}), + ...(composed.trace.compression ? { + compression: { + ...composed.trace.compression, + protectedSources: [...composed.trace.compression.protectedSources], + compressedSources: [...composed.trace.compression.compressedSources], + }, + } : {}), + }, }; } @@ -3239,26 +3310,6 @@ ${matrix}`, try { await this.rebuildCurrentStateFactHistory(bookDir, uptoChapter); } catch (error) { - if (this.isMemoryIndexUnavailableError(error)) { - if (this.canOpenMemoryIndex(bookDir)) { - try { - await this.rebuildCurrentStateFactHistory(bookDir, uptoChapter); - return; - } catch (retryError) { - error = retryError; - } - } else { - if (!this.memoryIndexFallbackWarned) { - this.memoryIndexFallbackWarned = true; - this.logWarn(await this.resolveBookLanguageById(bookId), { - zh: "当前 Node 运行时不支持 SQLite 记忆索引,继续使用 Markdown 回退方案。", - en: "SQLite memory index unavailable on this Node runtime; continuing with markdown fallback.", - }); - await this.logMemoryIndexDebugInfo(bookId, error); - } - return; - } - } this.logWarn(await this.resolveBookLanguageById(bookId), { zh: `状态事实同步已跳过:${String(error)}`, en: `State fact sync skipped: ${String(error)}`, @@ -3289,26 +3340,6 @@ ${matrix}`, try { await this.rebuildNarrativeMemoryIndex(bookDir); } catch (error) { - if (this.isMemoryIndexUnavailableError(error)) { - if (this.canOpenMemoryIndex(bookDir)) { - try { - await this.rebuildNarrativeMemoryIndex(bookDir); - return; - } catch (retryError) { - error = retryError; - } - } else { - if (!this.memoryIndexFallbackWarned) { - this.memoryIndexFallbackWarned = true; - this.logWarn(await this.resolveBookLanguageById(bookId), { - zh: "当前 Node 运行时不支持 SQLite 记忆索引,继续使用 Markdown 回退方案。", - en: "SQLite memory index unavailable on this Node runtime; continuing with markdown fallback.", - }); - await this.logMemoryIndexDebugInfo(bookId, error); - } - return; - } - } this.logWarn(await this.resolveBookLanguageById(bookId), { zh: `叙事记忆同步已跳过:${String(error)}`, en: `Narrative memory sync skipped: ${String(error)}`, @@ -3391,36 +3422,6 @@ ${matrix}`, } } - private canOpenMemoryIndex(bookDir: string): boolean { - let memoryDb: MemoryDB | null = null; - try { - memoryDb = new MemoryDB(bookDir); - return true; - } catch { - return false; - } finally { - memoryDb?.close(); - } - } - - private async logMemoryIndexDebugInfo(bookId: string, error: unknown): Promise { - if (process.env.INKOS_DEBUG_SQLITE_MEMORY !== "1") { - return; - } - - const code = typeof error === "object" && error !== null && "code" in error - ? String((error as { code?: unknown }).code ?? "") - : ""; - const message = error instanceof Error - ? error.message - : String(error); - - this.logWarn(await this.resolveBookLanguageById(bookId), { - zh: `SQLite 记忆索引调试:node=${process.version}; execArgv=${JSON.stringify(process.execArgv)}; code=${code || "(none)"}; message=${message}`, - en: `SQLite memory debug: node=${process.version}; execArgv=${JSON.stringify(process.execArgv)}; code=${code || "(none)"}; message=${message}`, - }); - } - private async withMemoryIndexRetry(operation: () => Promise | T): Promise { const retryDelaysMs = [0, 25, 75]; let lastError: unknown; @@ -3440,22 +3441,6 @@ ${matrix}`, throw lastError; } - private isMemoryIndexUnavailableError(error: unknown): boolean { - if (!error) return false; - - const code = typeof error === "object" && error !== null && "code" in error - ? String((error as { code?: unknown }).code ?? "") - : ""; - const message = error instanceof Error - ? error.message - : String(error); - const normalizedMessage = message.trim(); - - return /^No such built-in module:\s*node:sqlite$/i.test(normalizedMessage) - || /^Cannot find module ['"]node:sqlite['"]$/i.test(normalizedMessage) - || (code === "ERR_UNKNOWN_BUILTIN_MODULE" && /\bnode:sqlite\b/i.test(normalizedMessage)); - } - private isMemoryIndexBusyError(error: unknown): boolean { if (!error) return false; @@ -3731,6 +3716,8 @@ ${matrix}`, plan, contextBudget: contextBudgetFromClient(composerCtx.client), compressibleContextCompiler: (request) => composer.compileCompressibleContext(request), + outlineSectionSelector: (request) => composer.selectOutlineSections(request), + memorySemanticSelector: (request) => composer.selectMemoryCandidates(request), referenceContextProvider: (request) => selectBookReferenceContext( this.config.projectRoot, book.id, diff --git a/packages/core/src/retrieval/local-search.ts b/packages/core/src/retrieval/local-search.ts new file mode 100644 index 00000000..48033ba4 --- /dev/null +++ b/packages/core/src/retrieval/local-search.ts @@ -0,0 +1,284 @@ +import { createHash } from "node:crypto"; +import { mkdirSync } from "node:fs"; +import { dirname } from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +export interface SearchDocument { + readonly id: string; + readonly scope: string; + readonly kind: string; + readonly source: string; + readonly title: string; + readonly body: string; + readonly metadata?: Readonly>; +} + +export interface SearchHit extends SearchDocument { + readonly score: number; +} + +export interface MarkdownSearchSegment { + readonly heading: string; + readonly body: string; + readonly charStart: number; + readonly charEnd: number; +} + +/** + * The single lexical retrieval kernel used by story memory, archived materials, + * and Skill references. Source files remain authoritative; this database is a + * rebuildable FTS5 projection. + */ +export class LocalSearchIndex { + private readonly db: DatabaseSync; + + constructor(path: string) { + if (path !== ":memory:") mkdirSync(dirname(path), { recursive: true }); + this.db = new DatabaseSync(path); + this.db.exec("PRAGMA journal_mode = WAL"); + this.db.exec("PRAGMA foreign_keys = ON"); + this.migrate(); + } + + replaceScope(scope: string, documents: ReadonlyArray): void { + const normalized = documents.map((document) => normalizeDocument(document, scope)); + const keepIds = new Set(normalized.map((document) => document.id)); + const existing = this.db.prepare( + "SELECT document_id AS id, content_hash AS contentHash FROM retrieval_documents WHERE scope = ?", + ).all(scope) as unknown as ReadonlyArray<{ readonly id: string; readonly contentHash: string }>; + const existingHashes = new Map(existing.map((row) => [row.id, row.contentHash])); + const upsert = this.db.prepare(` + INSERT INTO retrieval_documents ( + document_id, scope, kind, source, title, body, + title_tokens, body_tokens, metadata_json, content_hash + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(scope, document_id) DO UPDATE SET + kind = excluded.kind, + source = excluded.source, + title = excluded.title, + body = excluded.body, + title_tokens = excluded.title_tokens, + body_tokens = excluded.body_tokens, + metadata_json = excluded.metadata_json, + content_hash = excluded.content_hash, + updated_at = datetime('now') + `); + const remove = this.db.prepare( + "DELETE FROM retrieval_documents WHERE scope = ? AND document_id = ?", + ); + + this.db.exec("BEGIN IMMEDIATE"); + try { + for (const document of normalized) { + if (existingHashes.get(document.id) === document.contentHash) continue; + upsert.run( + document.id, + document.scope, + document.kind, + document.source, + document.title, + document.body, + document.titleTokens, + document.bodyTokens, + document.metadataJson, + document.contentHash, + ); + } + for (const row of existing) { + if (!keepIds.has(row.id)) remove.run(scope, row.id); + } + this.db.exec("COMMIT"); + } catch (error) { + this.db.exec("ROLLBACK"); + throw error; + } + } + + search(query: string, options: { + readonly scope: string; + readonly kinds?: ReadonlyArray; + readonly limit?: number; + }): SearchHit[] { + const match = buildMatchQuery(query); + if (!match) return []; + const kinds = [...new Set(options.kinds ?? [])]; + const kindFilter = kinds.length > 0 + ? ` AND d.kind IN (${kinds.map(() => "?").join(", ")})` + : ""; + const limit = Math.max(1, Math.min(200, Math.floor(options.limit ?? 24))); + const rows = this.db.prepare(` + SELECT + d.document_id AS id, + d.scope, + d.kind, + d.source, + d.title, + d.body, + d.metadata_json AS metadataJson, + bm25(retrieval_documents_fts, 5.0, 1.0) AS rank + FROM retrieval_documents_fts + JOIN retrieval_documents d ON d.rowid = retrieval_documents_fts.rowid + WHERE retrieval_documents_fts MATCH ? + AND d.scope = ?${kindFilter} + ORDER BY rank ASC, d.document_id ASC + LIMIT ? + `).all(match, options.scope, ...kinds, limit) as unknown as ReadonlyArray<{ + readonly id: string; + readonly scope: string; + readonly kind: string; + readonly source: string; + readonly title: string; + readonly body: string; + readonly metadataJson: string; + readonly rank: number; + }>; + + return rows.map((row) => ({ + id: row.id, + scope: row.scope, + kind: row.kind, + source: row.source, + title: row.title, + body: row.body, + metadata: parseMetadata(row.metadataJson), + // SQLite's FTS5 bm25() returns smaller values for better matches. + score: -row.rank, + })); + } + + close(): void { + this.db.close(); + } + + private migrate(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS retrieval_documents ( + rowid INTEGER PRIMARY KEY AUTOINCREMENT, + document_id TEXT NOT NULL, + scope TEXT NOT NULL, + kind TEXT NOT NULL, + source TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL, + title_tokens TEXT NOT NULL, + body_tokens TEXT NOT NULL, + metadata_json TEXT NOT NULL DEFAULT '{}', + content_hash TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(scope, document_id) + ); + + CREATE INDEX IF NOT EXISTS idx_retrieval_documents_scope_kind + ON retrieval_documents(scope, kind); + + CREATE VIRTUAL TABLE IF NOT EXISTS retrieval_documents_fts USING fts5( + title_tokens, + body_tokens, + content='retrieval_documents', + content_rowid='rowid' + ); + + CREATE TRIGGER IF NOT EXISTS retrieval_documents_ai AFTER INSERT ON retrieval_documents BEGIN + INSERT INTO retrieval_documents_fts(rowid, title_tokens, body_tokens) + VALUES (new.rowid, new.title_tokens, new.body_tokens); + END; + + CREATE TRIGGER IF NOT EXISTS retrieval_documents_ad AFTER DELETE ON retrieval_documents BEGIN + INSERT INTO retrieval_documents_fts(retrieval_documents_fts, rowid, title_tokens, body_tokens) + VALUES ('delete', old.rowid, old.title_tokens, old.body_tokens); + END; + + CREATE TRIGGER IF NOT EXISTS retrieval_documents_au AFTER UPDATE ON retrieval_documents BEGIN + INSERT INTO retrieval_documents_fts(retrieval_documents_fts, rowid, title_tokens, body_tokens) + VALUES ('delete', old.rowid, old.title_tokens, old.body_tokens); + INSERT INTO retrieval_documents_fts(rowid, title_tokens, body_tokens) + VALUES (new.rowid, new.title_tokens, new.body_tokens); + END; + `); + } +} + +export function splitMarkdownForSearch(markdown: string): MarkdownSearchSegment[] { + const segments: MarkdownSearchSegment[] = []; + let heading = ""; + const blockPattern = /(?:^|\n)([^\n](?:[\s\S]*?))(?=\n\s*\n|$)/g; + for (const match of markdown.matchAll(blockPattern)) { + const raw = match[1] ?? ""; + const leadingOffset = (match[0]?.length ?? raw.length) - raw.length; + const charStart = (match.index ?? 0) + leadingOffset; + const body = raw.trim(); + if (!body) continue; + const headingMatch = body.match(/^#{1,6}\s+(.+)$/); + if (headingMatch) { + heading = headingMatch[1]?.trim() ?? heading; + continue; + } + segments.push({ + heading, + body, + charStart, + charEnd: charStart + raw.length, + }); + } + return segments; +} + +export function tokenizeSearchText(text: string): string[] { + const normalized = text.normalize("NFKC").toLocaleLowerCase(); + const segmenter = new Intl.Segmenter(undefined, { granularity: "word" }); + const tokens: string[] = []; + for (const part of segmenter.segment(normalized)) { + const token = part.segment.trim(); + if (!part.isWordLike || !token) continue; + if (/^[\p{Script=Latin}\p{N}_-]+$/u.test(token) && token.length < 2) continue; + tokens.push(token); + } + // ICU can segment short Chinese compounds into individual Han characters. + // Adjacent Han bigrams preserve those compounds for lexical retrieval; this + // is tokenizer mechanics, not a semantic intent rule. + const segmentedTokenCount = tokens.length; + for (let index = 0; index < segmentedTokenCount - 1; index += 1) { + const left = tokens[index] ?? ""; + const right = tokens[index + 1] ?? ""; + if (/^\p{Script=Han}$/u.test(left) && /^\p{Script=Han}$/u.test(right)) { + tokens.push(`${left}${right}`); + } + } + for (const match of normalized.matchAll(/[\p{L}\p{N}]+(?:[-_][\p{L}\p{N}]+)+/gu)) { + tokens.push(match[0]); + } + return tokens; +} + +function buildMatchQuery(query: string): string { + const tokens = [...new Set(tokenizeSearchText(query))].slice(0, 64); + return tokens.map((token) => `"${token.replaceAll('"', '""')}"`).join(" OR "); +} + +function normalizeDocument(document: SearchDocument, scope: string) { + const normalized = { ...document, scope }; + const metadataJson = JSON.stringify(document.metadata ?? {}); + const titleTokens = tokenizeSearchText(document.title).join(" "); + const bodyTokens = tokenizeSearchText(document.body).join(" "); + const contentHash = createHash("sha256") + .update([normalized.kind, normalized.source, normalized.title, normalized.body, metadataJson].join("\0")) + .digest("hex"); + return { + ...normalized, + metadataJson, + titleTokens, + bodyTokens, + contentHash, + }; +} + +function parseMetadata(value: string): Readonly> { + try { + const parsed = JSON.parse(value) as unknown; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed as Readonly> + : {}; + } catch { + return {}; + } +} diff --git a/packages/core/src/utils/context-assembly.ts b/packages/core/src/utils/context-assembly.ts index 59ed0f50..aa6a7cfb 100644 --- a/packages/core/src/utils/context-assembly.ts +++ b/packages/core/src/utils/context-assembly.ts @@ -91,6 +91,7 @@ export function buildGovernedTrace(params: { readonly notes?: ReadonlyArray; readonly promptPacks?: ReadonlyArray; readonly compression?: ChapterTrace["compression"]; + readonly retrieval?: ChapterTrace["retrieval"]; }): ChapterTrace { const protectedEntries = params.contextPackage.selectedContext.filter((entry) => isProtectedContextSource(entry.source), @@ -117,6 +118,7 @@ export function buildGovernedTrace(params: { totalSelectedTokens: protectedTokens + compressibleTokens, }, ...(params.compression ? { compression: params.compression } : {}), + ...(params.retrieval ? { retrieval: params.retrieval } : {}), notes: params.notes ?? [], }); } diff --git a/packages/core/src/utils/memory-retrieval.ts b/packages/core/src/utils/memory-retrieval.ts index 62d0dc08..be88c50c 100644 --- a/packages/core/src/utils/memory-retrieval.ts +++ b/packages/core/src/utils/memory-retrieval.ts @@ -20,6 +20,11 @@ import { renderHookSnapshot, renderSummarySnapshot, } from "./story-markdown.js"; +import { + LocalSearchIndex, + type SearchDocument, + type SearchHit, +} from "../retrieval/local-search.js"; export { isFuturePlannedHook, isHookWithinChapterWindow, @@ -44,9 +49,38 @@ export interface MemorySelection { readonly recyclableHooks: ReadonlyArray; readonly facts: ReadonlyArray; readonly volumeSummaries: ReadonlyArray; - readonly dbPath?: string; + readonly dbPath: string; + readonly retrievalTrace: MemoryRetrievalTrace; } +export interface MemoryRetrievalTrace { + readonly engine: "sqlite-fts5-bm25"; + readonly query: string; + readonly candidates: ReadonlyArray<{ + readonly id: string; + readonly kind: string; + readonly source: string; + readonly score: number; + }>; + readonly semanticSelectedIds?: ReadonlyArray; +} + +export interface MemorySemanticSelectionRequest { + readonly chapterNumber: number; + readonly query: string; + readonly candidates: ReadonlyArray<{ + readonly id: string; + readonly kind: string; + readonly source: string; + readonly title: string; + readonly excerpt: string; + }>; +} + +export type MemorySemanticSelector = ( + request: MemorySemanticSelectionRequest, +) => Promise>; + export interface VolumeSummarySelection { readonly heading: string; readonly content: string; @@ -59,6 +93,7 @@ export async function retrieveMemorySelection(params: { readonly goal: string; readonly outlineNode?: string; readonly mustKeep?: ReadonlyArray; + readonly semanticSelector?: MemorySemanticSelector; }): Promise { const storyDir = join(params.bookDir, "story"); const stateDir = join(storyDir, "state"); @@ -88,81 +123,103 @@ export async function retrieveMemorySelection(params: { currentStateMarkdown, fallbackChapter, ); - const narrativeQueryTerms = extractQueryTerms( - params.goal, - params.outlineNode, - [], - ); - const factQueryTerms = extractQueryTerms( - params.goal, - params.outlineNode, - params.mustKeep ?? [], - ); - const volumeSummaries = selectRelevantVolumeSummaries( - parseVolumeSummariesMarkdown(volumeSummariesMarkdown), - narrativeQueryTerms, - ); + const narrativeQuery = [params.goal, params.outlineNode ?? ""].filter(Boolean).join("\n"); + const retrievalQuery = [narrativeQuery, ...(params.mustKeep ?? [])].filter(Boolean).join("\n"); + const parsedVolumeSummaries = parseVolumeSummariesMarkdown(volumeSummariesMarkdown); // Hooks stay on the authority path instead of the SQLite acceleration path: // the DB table intentionally stores only a small subset and cannot preserve // promoted/core/dependency metadata, which is load-bearing for hook debt. const hooks = structuredHooks?.hooks ?? parsePendingHooksMarkdown(hooksMarkdown); const activeHooks = filterActiveHooks(hooks); - const memoryDb = openMemoryDB(params.bookDir); - if (memoryDb) { - try { - if (memoryDb.getChapterCount() === 0) { - const summaries = structuredSummaries?.rows ?? parseChapterSummariesMarkdown( - await readFile(join(storyDir, "chapter_summaries.md"), "utf-8").catch(() => ""), - ); - if (summaries.length > 0) { - memoryDb.replaceSummaries(summaries); - } - } - if (memoryDb.getCurrentFacts().length === 0 && facts.length > 0) { - memoryDb.replaceCurrentFacts(facts); - } + const summaries = structuredSummaries?.rows ?? parseChapterSummariesMarkdown( + await readFile(join(storyDir, "chapter_summaries.md"), "utf-8").catch(() => ""), + ); + const memoryDb = new MemoryDB(params.bookDir); + try { + memoryDb.replaceSummaries(summaries); + memoryDb.replaceCurrentFacts(facts); - // Structured/markdown hook state is authoritative because it preserves - // metadata the SQLite acceleration table does not. In migration/minimal - // projects that projection can be absent or empty while SQLite already - // has usable hook rows, so fall back only when the authority path yields - // no active hooks at all. - const effectiveActiveHooks = activeHooks.length > 0 - ? activeHooks - : filterActiveHooks(memoryDb.getActiveHooks()); + // Markdown/structured hook state is authoritative. SQLite is a rebuildable + // search projection and is never allowed to resurrect stale hook rows. + const effectiveActiveHooks = activeHooks; + const dbPath = join(storyDir, "memory.db"); + const searchIndex = new LocalSearchIndex(dbPath); + try { + searchIndex.replaceScope( + STORY_MEMORY_SCOPE, + buildMemorySearchDocuments({ + summaries, + hooks: effectiveActiveHooks, + facts, + volumeSummaries: parsedVolumeSummaries, + }), + ); + const hits = searchIndex.search(retrievalQuery, { + scope: STORY_MEMORY_SCOPE, + limit: 32, + }); + const semanticSelectedIds = await selectSemanticCandidateIds({ + selector: params.semanticSelector, + chapterNumber: params.chapterNumber, + query: retrievalQuery, + hits, + }); + const selectedSet = semanticSelectedIds ? new Set(semanticSelectedIds) : null; + const rankedHits = selectedSet ? hits.filter((hit) => selectedSet.has(hit.id)) : hits; + const rankScores = buildRankScores(rankedHits); return { - summaries: selectRelevantSummaries( - memoryDb.getSummaries(1, Math.max(1, params.chapterNumber - 1)), - params.chapterNumber, - narrativeQueryTerms, - ), - hooks: selectRelevantHooks(effectiveActiveHooks, narrativeQueryTerms, params.chapterNumber), + summaries: selectRelevantSummaries(summaries, params.chapterNumber, rankScores), + hooks: selectRelevantHooks(effectiveActiveHooks, rankScores, params.chapterNumber), activeHooks: effectiveActiveHooks, recyclableHooks: computeRecyclableHooks(effectiveActiveHooks, params.chapterNumber), - facts: selectRelevantFacts(memoryDb.getCurrentFacts(), factQueryTerms), - volumeSummaries, - dbPath: join(storyDir, "memory.db"), + facts: selectRelevantFacts(facts, rankScores), + volumeSummaries: selectRelevantVolumeSummaries(parsedVolumeSummaries, rankScores), + dbPath, + retrievalTrace: { + engine: "sqlite-fts5-bm25", + query: retrievalQuery, + candidates: hits.map(({ id, kind, source, score }) => ({ id, kind, source, score })), + ...(semanticSelectedIds ? { semanticSelectedIds } : {}), + }, }; } finally { - memoryDb.close(); + searchIndex.close(); } + } finally { + memoryDb.close(); } +} - const [summariesMarkdown] = await Promise.all([ - readFile(join(storyDir, "chapter_summaries.md"), "utf-8").catch(() => ""), - ]); - const summaries = structuredSummaries?.rows ?? parseChapterSummariesMarkdown(summariesMarkdown); +const STORY_MEMORY_SCOPE = "story-memory"; - return { - summaries: selectRelevantSummaries(summaries, params.chapterNumber, narrativeQueryTerms), - hooks: selectRelevantHooks(activeHooks, narrativeQueryTerms, params.chapterNumber), - activeHooks, - recyclableHooks: computeRecyclableHooks(activeHooks, params.chapterNumber), - facts: selectRelevantFacts(facts, factQueryTerms), - volumeSummaries, - }; +async function selectSemanticCandidateIds(params: { + readonly selector?: MemorySemanticSelector; + readonly chapterNumber: number; + readonly query: string; + readonly hits: ReadonlyArray; +}): Promise | undefined> { + if (!params.selector || params.hits.length <= 1) return undefined; + try { + const allowed = new Set(params.hits.map((hit) => hit.id)); + const selected = await params.selector({ + chapterNumber: params.chapterNumber, + query: params.query, + candidates: params.hits.map((hit) => ({ + id: hit.id, + kind: hit.kind, + source: hit.source, + title: hit.title, + excerpt: hit.body, + })), + }); + return [...new Set(selected)].filter((id) => allowed.has(id)); + } catch { + // Retrieval remains available if the semantic selector is temporarily + // unavailable; BM25 and deterministic story-state priorities still apply. + return undefined; + } } /** @@ -210,30 +267,6 @@ function recycleThreshold(hook: StoredHook): number { return 10; } -export function extractQueryTerms(goal: string, outlineNode: string | undefined, mustKeep: ReadonlyArray): string[] { - const primaryTerms = uniqueTerms([ - ...extractTermsFromText(stripNegativeGuidance(goal)), - ...mustKeep.flatMap((item) => extractTermsFromText(item)), - ]); - - if (primaryTerms.length >= 2) { - return primaryTerms.slice(0, 12); - } - - return uniqueTerms([ - ...primaryTerms, - ...extractTermsFromText(stripNegativeGuidance(outlineNode ?? "")), - ]).slice(0, 12); -} - -function openMemoryDB(bookDir: string): MemoryDB | null { - try { - return new MemoryDB(bookDir); - } catch { - return null; - } -} - async function readStructuredState( path: string, schema: { parse(value: unknown): T }, @@ -246,90 +279,81 @@ async function readStructuredState( } } -function buildLegacyQueryTerms(goal: string, outlineNode: string | undefined, mustKeep: ReadonlyArray): string[] { - const stopWords = new Set([ - "bring", "focus", "back", "chapter", "clear", "narrative", "before", "opening", - "track", "the", "with", "from", "that", "this", "into", "still", "cannot", - "current", "state", "advance", "conflict", "story", "keep", "must", "local", - ]); - - const source = [goal, outlineNode ?? "", ...mustKeep].join(" "); - const english = source.match(/[a-z]{4,}/gi) ?? []; - const chinese = source.match(/[\u4e00-\u9fff]{2,4}/g) ?? []; - - return [...new Set( - [...english, ...chinese] - .map((term) => term.trim()) - .filter((term) => term.length >= 2) - .filter((term) => !stopWords.has(term.toLowerCase())), - )].slice(0, 12); +function buildMemorySearchDocuments(input: { + readonly summaries: ReadonlyArray; + readonly hooks: ReadonlyArray; + readonly facts: ReadonlyArray; + readonly volumeSummaries: ReadonlyArray; +}): SearchDocument[] { + return [ + ...input.summaries.map((summary) => ({ + id: summaryDocumentId(summary.chapter), + scope: STORY_MEMORY_SCOPE, + kind: "chapter-summary", + source: `story/chapter_summaries.md#${summary.chapter}`, + title: summary.title || `Chapter ${summary.chapter}`, + body: [ + summary.characters, + summary.events, + summary.stateChanges, + summary.hookActivity, + summary.mood, + summary.chapterType, + ].filter(Boolean).join("\n"), + metadata: { chapter: summary.chapter }, + })), + ...input.hooks.map((hook) => ({ + id: hookDocumentId(hook.hookId), + scope: STORY_MEMORY_SCOPE, + kind: "hook", + source: `story/pending_hooks.md#${hook.hookId}`, + title: [hook.hookId, hook.type].filter(Boolean).join(" "), + body: [hook.status, hook.expectedPayoff, hook.payoffTiming, hook.notes].filter(Boolean).join("\n"), + metadata: { hookId: hook.hookId }, + })), + ...input.facts.map((fact, index) => ({ + id: factDocumentId(index), + scope: STORY_MEMORY_SCOPE, + kind: "fact", + source: `story/current_state.md#${toFactSourceAnchor(fact.predicate)}`, + title: [fact.subject, fact.predicate].filter(Boolean).join(" "), + body: fact.object, + metadata: { index }, + })), + ...input.volumeSummaries.map((summary, index) => ({ + id: volumeSummaryDocumentId(index), + scope: STORY_MEMORY_SCOPE, + kind: "volume-summary", + source: `story/volume_summaries.md#${summary.anchor}`, + title: summary.heading, + body: summary.content, + metadata: { index }, + })), + ]; } -function extractTermsFromText(text: string): string[] { - if (!text.trim()) return []; - - const stopWords = new Set([ - "bring", "focus", "back", "chapter", "clear", "narrative", "before", "opening", - "track", "the", "with", "from", "that", "this", "into", "still", "cannot", - "current", "state", "advance", "conflict", "story", "keep", "must", "local", - "does", "not", "only", "just", "then", "than", - ]); - - const normalized = text.replace(/第\d+章/g, " "); - const english = (normalized.match(/[a-z]{4,}/gi) ?? []) - .map((term) => term.trim()) - .filter((term) => term.length >= 2) - .filter((term) => !stopWords.has(term.toLowerCase())); - - const chineseSegments = normalized.match(/[\u4e00-\u9fff]{2,}/g) ?? []; - const chinese = chineseSegments.flatMap((segment) => extractChineseFocusTerms(segment)); - - return [...english, ...chinese]; +function buildRankScores(hits: ReadonlyArray): ReadonlyMap { + return new Map(hits.map((hit, index) => [hit.id, (hits.length - index) * 10])); } -function extractChineseFocusTerms(segment: string): string[] { - const stripped = segment - .replace(/^(本章|继续|重新|拉回|回到|推进|优先|围绕|聚焦|坚持|保持|把注意力|注意力|将注意力|请把注意力|先把注意力)+/, "") - .replace(/^(处理|推进|回拉|拉回到)+/, "") - .trim(); - - const target = stripped.length >= 2 ? stripped : segment; - const terms = new Set(); - - if (target.length <= 4) { - terms.add(target); - } - - for (let size = 2; size <= 4; size += 1) { - if (target.length >= size) { - terms.add(target.slice(-size)); - } - } - - return [...terms].filter((term) => term.length >= 2); +function summaryDocumentId(chapter: number): string { + return `summary:${chapter}`; } -function stripNegativeGuidance(text: string): string { - if (!text) return ""; - - return text - .replace(/\b(do not|don't|avoid|without|instead of)\b[\s\S]*$/i, " ") - .replace(/(?:不要|不让|别|禁止|避免|但不允许)[\s\S]*$/u, " ") - .trim(); +function hookDocumentId(hookId: string): string { + return `hook:${hookId}`; } -function uniqueTerms(terms: ReadonlyArray): string[] { - const result: string[] = []; - const seen = new Set(); +function factDocumentId(index: number): string { + return `fact:${index}`; +} - for (const term of terms) { - const normalized = term.trim().toLowerCase(); - if (!normalized || seen.has(normalized)) continue; - seen.add(normalized); - result.push(term.trim()); - } +function volumeSummaryDocumentId(index: number): string { + return `volume-summary:${index}`; +} - return result; +function toFactSourceAnchor(value: string): string { + return value.trim().replaceAll(/\s+/g, "-") || "fact"; } function parseVolumeSummariesMarkdown(markdown: string): VolumeSummarySelection[] { @@ -360,57 +384,58 @@ function isUnresolvedHook(status: string): boolean { function selectRelevantSummaries( summaries: ReadonlyArray, chapterNumber: number, - queryTerms: ReadonlyArray, + rankScores: ReadonlyMap, ): StoredSummary[] { - return summaries + const ranked = summaries .filter((summary) => summary.chapter < chapterNumber) - .map((summary) => ({ - summary, - score: scoreSummary(summary, chapterNumber, queryTerms), - matched: matchesAny([ - summary.title, - summary.characters, - summary.events, - summary.stateChanges, - summary.hookActivity, - summary.chapterType, - ].join(" "), queryTerms), - })) - .filter((entry) => entry.matched || entry.summary.chapter >= chapterNumber - 3) + .map((summary) => { + const age = Math.max(0, chapterNumber - summary.chapter); + const retrievalScore = rankScores.get(summaryDocumentId(summary.chapter)) ?? 0; + return { + summary, + score: retrievalScore + Math.max(0, 12 - age), + retrieved: retrievalScore > 0, + }; + }); + const recent = ranked + .filter((entry) => entry.summary.chapter >= chapterNumber - 3) + .sort((left, right) => right.summary.chapter - left.summary.chapter) + .slice(0, 3); + const recalled = ranked + .filter((entry) => entry.retrieved) .sort((left, right) => right.score - left.score || right.summary.chapter - left.summary.chapter) - .slice(0, 4) - .map((entry) => entry.summary) + .slice(0, 1); + + return [...new Map([...recent, ...recalled].map((entry) => [entry.summary.chapter, entry.summary])).values()] .sort((left, right) => left.chapter - right.chapter); } function selectRelevantHooks( hooks: ReadonlyArray, - queryTerms: ReadonlyArray, + rankScores: ReadonlyMap, chapterNumber: number, ): StoredHook[] { const ranked = hooks - .map((hook) => ({ - hook, - score: scoreHook(hook, queryTerms, chapterNumber), - matched: matchesAny( - [hook.hookId, hook.type, hook.expectedPayoff, hook.payoffTiming ?? "", hook.notes].join(" "), - queryTerms, - ), - })) - .filter((entry: { hook: StoredHook; score: number; matched: boolean }) => - entry.matched || isUnresolvedHook(entry.hook.status), - ); + .map((hook) => { + const retrievalScore = rankScores.get(hookDocumentId(hook.hookId)) ?? 0; + return { + hook, + score: retrievalScore + Math.max(0, hook.lastAdvancedChapter), + retrieved: retrievalScore > 0, + }; + }) + .filter((entry) => entry.retrieved || isUnresolvedHook(entry.hook.status)); const primary = ranked - .filter((entry: { hook: StoredHook; score: number; matched: boolean }) => - entry.matched || isHookWithinChapterWindow(entry.hook, chapterNumber, 5), + .filter((entry) => + entry.retrieved || isHookWithinChapterWindow(entry.hook, chapterNumber, 5), ) .sort((left, right) => right.score - left.score || right.hook.lastAdvancedChapter - left.hook.lastAdvancedChapter) .slice(0, 6); - const selectedIds = new Set(primary.map((entry: { hook: StoredHook; score: number; matched: boolean }) => entry.hook.hookId)); + const selectedIds = new Set(primary.map((entry) => entry.hook.hookId)); const stale = ranked - .filter((entry: { hook: StoredHook; score: number; matched: boolean }) => + .filter((entry) => !selectedIds.has(entry.hook.hookId) && !isFuturePlannedHook(entry.hook, chapterNumber) && isUnresolvedHook(entry.hook.status), @@ -418,39 +443,36 @@ function selectRelevantHooks( .sort((left, right) => left.hook.lastAdvancedChapter - right.hook.lastAdvancedChapter || right.score - left.score) .slice(0, 2); - return [...primary, ...stale].map((entry: { hook: StoredHook; score: number; matched: boolean }) => entry.hook); + return [...primary, ...stale].map((entry) => entry.hook); } function selectRelevantFacts( facts: ReadonlyArray, - queryTerms: ReadonlyArray, + rankScores: ReadonlyMap, ): Fact[] { const prioritizedPredicates = [ - /^(当前冲突|current conflict)$/i, - /^(当前目标|current goal)$/i, - /^(主角状态|protagonist state)$/i, - /^(当前限制|current constraint)$/i, - /^(当前位置|current location)$/i, - /^(当前敌我|current alliances|current relationships)$/i, + ["当前冲突", "current conflict"], + ["当前目标", "current goal"], + ["主角状态", "protagonist state"], + ["当前限制", "current constraint"], + ["当前位置", "current location"], + ["当前敌我", "current alliances", "current relationships"], ]; return facts - .map((fact) => { - const text = [fact.subject, fact.predicate, fact.object].join(" "); - const priority = prioritizedPredicates.findIndex((pattern) => pattern.test(fact.predicate)); + .map((fact, index) => { + const normalizedPredicate = fact.predicate.trim().toLocaleLowerCase(); + const priority = prioritizedPredicates.findIndex((values) => values.includes(normalizedPredicate)); const baseScore = priority === -1 ? 5 : 20 - priority * 2; - const termScore = queryTerms.reduce( - (score, term) => score + (includesTerm(text, term) ? Math.max(8, term.length * 2) : 0), - 0, - ); + const retrievalScore = rankScores.get(factDocumentId(index)) ?? 0; return { fact, - score: baseScore + termScore, - matched: matchesAny(text, queryTerms), + score: baseScore + retrievalScore, + retrieved: retrievalScore > 0, }; }) - .filter((entry) => entry.matched || entry.score >= 14) + .filter((entry) => entry.retrieved || entry.score >= 14) .sort((left, right) => right.score - left.score) .slice(0, 4) .map((entry) => entry.fact); @@ -458,26 +480,21 @@ function selectRelevantFacts( function selectRelevantVolumeSummaries( summaries: ReadonlyArray, - queryTerms: ReadonlyArray, + rankScores: ReadonlyMap, ): VolumeSummarySelection[] { if (summaries.length === 0) return []; const ranked = summaries .map((summary, index) => { - const text = `${summary.heading} ${summary.content}`; - const termScore = queryTerms.reduce( - (score, term) => score + (includesTerm(text, term) ? Math.max(8, term.length * 2) : 0), - 0, - ); - + const retrievalScore = rankScores.get(volumeSummaryDocumentId(index)) ?? 0; return { index, summary, - score: termScore + index, - matched: matchesAny(text, queryTerms), + score: retrievalScore + index, + retrieved: retrievalScore > 0, }; }) - .filter((entry, index, all) => entry.matched || index === all.length - 1) + .filter((entry, index, all) => entry.retrieved || index === all.length - 1) .sort((left, right) => right.score - left.score) .slice(0, 2) .sort((left, right) => left.index - right.index) @@ -486,40 +503,6 @@ function selectRelevantVolumeSummaries( return ranked; } -function scoreSummary(summary: StoredSummary, chapterNumber: number, queryTerms: ReadonlyArray): number { - const text = [ - summary.title, - summary.characters, - summary.events, - summary.stateChanges, - summary.hookActivity, - summary.chapterType, - ].join(" "); - const age = Math.max(0, chapterNumber - summary.chapter); - const recencyScore = Math.max(0, 12 - age); - const termScore = queryTerms.reduce((score, term) => score + (includesTerm(text, term) ? Math.max(8, term.length * 2) : 0), 0); - return recencyScore + termScore; -} - -function scoreHook( - hook: StoredHook, - queryTerms: ReadonlyArray, - _chapterNumber: number, -): number { - const text = [hook.hookId, hook.type, hook.expectedPayoff, hook.payoffTiming ?? "", hook.notes].join(" "); - const freshness = Math.max(0, hook.lastAdvancedChapter); - const termScore = queryTerms.reduce((score, term) => score + (includesTerm(text, term) ? Math.max(8, term.length * 2) : 0), 0); - return termScore + freshness; -} - -function matchesAny(text: string, queryTerms: ReadonlyArray): boolean { - return queryTerms.some((term) => includesTerm(text, term)); -} - -function includesTerm(text: string, term: string): boolean { - return text.toLowerCase().includes(term.toLowerCase()); -} - function slugifyAnchor(value: string): string { return value .trim() diff --git a/scripts/audit-semantic-patterns.mjs b/scripts/audit-semantic-patterns.mjs index fd51774b..8ed3c838 100644 --- a/scripts/audit-semantic-patterns.mjs +++ b/scripts/audit-semantic-patterns.mjs @@ -72,6 +72,12 @@ function isLikelySemanticDecision(path, line, windowText) { if (!hasAny(`${line}\n${windowText}`, SEMANTIC_HINTS)) return false; if (line.includes("CHAT_EDIT_TEXT_EXTENSIONS")) return false; if (line.includes("SAFE_ROLE_TRUTH_FILE_RE")) return false; + if (path.endsWith("agent-tools.ts") && line.includes("current.includes(")) return false; + if (path.endsWith("skill-tool.ts") && line.includes("body.includes(\"\\0\")")) return false; + if (line.includes("RUNTIME_DIAGNOSTIC_FILE_RE")) return false; + if (line.includes("runtimeDiagnostic")) return false; + if (line.includes("startsWith(\"scene-turn-\")")) return false; + if (line.includes("PlannerParseError") && line.includes(".test(text)")) return false; if (line.includes("CODE_FENCE_RE") || line.includes("DIRECTIVE_CLOSE_RE")) return false; if (line.includes("safeSessionId")) return false; if (line.includes("genreId")) return false; @@ -137,3 +143,5 @@ if (process.argv.includes("--json")) { console.log(`${finding.file}:${finding.line} ${finding.text}`); } } + +if (findings.length > 0) process.exitCode = 1;