refactor(core): unify pi harness retrieval and skills

This commit is contained in:
Ma
2026-08-14 13:41:59 +08:00
parent 493df3a82e
commit c56586ecb3
30 changed files with 1388 additions and 1773 deletions
@@ -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.
@@ -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.
@@ -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 () => {
@@ -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/<name>.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/<name>.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"/);
@@ -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");
});
});
+117 -9
View File
@@ -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<unknown>) => 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<unknown>) => 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", () => {
@@ -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({
@@ -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);
@@ -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"]));
});
});
@@ -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<string, unknown>).extractQueryTerms as
| ((goal: string, outlineNode: string | undefined, mustKeep: ReadonlyArray<string>) => ReadonlyArray<string>)
| 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<string, unknown>).extractQueryTerms as
| ((goal: string, outlineNode: string | undefined, mustKeep: ReadonlyArray<string>) => ReadonlyArray<string>)
| 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 |",
@@ -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<typeof memoryDbModule.MemoryDB>) => {
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({
@@ -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");
@@ -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(
+27 -59
View File
@@ -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<string>;
turnSkills: Map<string, ActivatedSkillGuidance>;
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<typeof createUseSkillTool>;
readonly requestedSkillIds?: () => ReadonlyArray<string>;
readonly activeSkills?: () => ReadonlyArray<ActivatedSkillGuidance>;
readonly workerSkills?: (agent: string) => ReadonlyArray<ActivatedSkillGuidance>;
};
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<string, ActivatedSkillGuidance>(
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<void> => {
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();
+26 -112
View File
@@ -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
- readgrepls
- session
- schema
-
- sub_agent
##
##
- sub_agent
- agent="writer" chapterCount1-20 1chapterWordCount
- agent="auditor" chapterNumber
- agent="reviser" chapterNumberchapterNumber, 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.mdoutline/volume_map.mdroles/major/<name>.mdroles/minor/<name>.md current_focus.mdauthor_intent.mdcurrent_state.md
- write_truth_file patch_chapter_text roles//<角色名>.md roles/major/<name>.md roles//<角色名>.md roles/minor/<name>.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 readlspatch_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) writerwriter
- / / sub_agent(agent="writer") reviser
- read/ls sub_agentsub_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/<name>.md, roles/minor/<name>.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/<name>.md or roles//<name>.md; minor characters under roles/minor/<name>.md or roles//<name>.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)}`;
}
+78 -31
View File
@@ -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<T>(
pipeline: PipelineRunner,
signal: AbortSignal | undefined,
activatedSkills: ReadonlyArray<ActivatedSkillGuidance>,
task: () => Promise<T>,
): Promise<T> {
// PipelineRunner owns cancellation and professional-guidance scope. The
// fallback keeps lightweight embedders/test doubles source-compatible.
const runner = pipeline as PipelineRunner & {
runWithAgentContext?: <R>(
context: {
readonly signal?: AbortSignal;
readonly activatedSkills?: ReadonlyArray<ActivatedSkillGuidance>;
},
activeTask: () => Promise<R>,
) => Promise<R>;
runWithAbortSignal?: <R>(activeSignal: AbortSignal | undefined, activeTask: () => Promise<R>) => Promise<R>;
};
return runAsWorkflowTrajectory(() => (
runner.runWithAgentContext
? runner.runWithAgentContext({ signal, activatedSkills }, task)
: runner.runWithAbortSignal
? runner.runWithAbortSignal(signal, task)
: task()
));
}
function runPipelineWithAbortSignal<T>(
pipeline: PipelineRunner,
signal: AbortSignal | undefined,
task: () => Promise<T>,
): Promise<T> {
// PipelineRunner owns the async cancellation scope. The fallback keeps
// lightweight embedders/test doubles source-compatible.
const runner = pipeline as PipelineRunner & {
runWithAbortSignal?: <R>(activeSignal: AbortSignal | undefined, activeTask: () => Promise<R>) => Promise<R>;
};
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<ActivatedSkillGuidance>;
readonly workerSkills?: (agent: string) => ReadonlyArray<ActivatedSkillGuidance>;
} = {},
): AgentTool<any> {
const sessionIsZh = (options.language ?? "zh") !== "en";
@@ -706,6 +726,11 @@ export function createSubAgentTool(
): Promise<AgentToolResult<unknown>> {
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<ReadonlyArray<ActivatedSkillGuidance>>
): ActivatedSkillGuidance[] {
const merged = new Map<string, ActivatedSkillGuidance>();
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,
+137 -4
View File
@@ -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<typeof UseSkillParams>;
@@ -22,7 +30,20 @@ type UseSkillParamsType = Static<typeof UseSkillParams>;
export interface CreateUseSkillToolOptions {
readonly registry: SkillRegistry;
readonly disabledSkillIds?: ReadonlyArray<string>;
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<ActivatedSkillResource>;
}
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<string[]> {
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<T>(text: string, details: T): AgentToolResult<T> {
return { content: [{ type: "text", text }], details };
}
+36 -2
View File
@@ -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<ActivatedSkillGuidance>;
}
export abstract class BaseAgent {
@@ -29,7 +31,10 @@ export abstract class BaseAgent {
messages: ReadonlyArray<LLMMessage>,
options?: { readonly temperature?: number; readonly maxTokens?: number },
): Promise<LLMResponse> {
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<LLMResponse> {
// 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<LLMMessage>,
activations: ReadonlyArray<ActivatedSkillGuidance> | undefined,
): ReadonlyArray<LLMMessage> {
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);
}
+73 -14
View File
@@ -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<ReadonlyArray<string>> {
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<ReadonlyArray<string>> {
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<ContextPackage["selectedContext"]> {
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<typeof entry> => entry !== null),
...outlineEntries,
...canonEntries.filter((entry): entry is NonNullable<typeof entry> => entry !== null),
...trailEntries,
...hookDebtEntries,
...factEntries,
...summaryEntries,
...volumeSummaryEntries,
...hookEntries,
];
return {
entries: [
...chapterMemoEntry,
...entries.filter((entry): entry is NonNullable<typeof entry> => entry !== null),
...outlineEntries,
...canonEntries.filter((entry): entry is NonNullable<typeof entry> => entry !== null),
...trailEntries,
...hookDebtEntries,
...factEntries,
...summaryEntries,
...volumeSummaryEntries,
...hookEntries,
],
retrievalTrace: memorySelection.retrievalTrace,
};
}
function deriveRetrievalHints(plan: PlanChapterOutput): string[] {
+1 -482
View File
@@ -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味铁律
- "他想看陆焚能不能活"
- "核心动机""信息边界""信息落差""核心风险""利益最大化""当前处境""核心风险不在今晚吵赢" "他心里转了一圈,知道今晚不是吵赢的问题"
- /仿30001
- /"火在体内流动"
- "当前处境""核心动机""信息边界""性格过滤"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 23 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<number, string> = {
1: `### 第一章:抛出核心冲突
- /
- "看到"
- ** 300 /**穿
- ** 1-2 2 + 1 /**"穿红衣的女人""跛脚老头"
- //
-
- `,
2: `### 第二章:展现金手指/核心能力
- //
- "我获得了XX"
- "主角有什么不同"
-
- 线`,
3: `### 第三章:明确短期目标
-
- //"变强"
- "接下来主角要干什么"
- `,
};
const enRules: Record<number, string> = {
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)
-3
View File
@@ -252,9 +252,6 @@ export {
normalizeSkillIdList,
normalizeRequestedIntent,
normalizePlayMode,
isExplicitWriteChapterCommand,
isUsablePlayInitialScene,
isWriteNextInstruction,
} from "./interaction/action-envelope.js";
export {
ExecutionStatusSchema,
@@ -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);
}
+9 -22
View File
@@ -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: `Im 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: "Im 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: `Im 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: "Im 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),
+64 -63
View File
@@ -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<RetrievedMaterial[]> {
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<string>();
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<MaterialAsset[]> {
@@ -87,50 +111,27 @@ async function listMaterialAssets(projectRoot: string): Promise<MaterialAsset[]>
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<string>();
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<Record<string, unknown>> | 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),
};
}
@@ -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([]),
});
+83 -96
View File
@@ -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<string>;
readonly protectedSources: ReadonlyArray<string>;
readonly compressibleSources: ReadonlyArray<string>;
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<string>;
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<string, LLMClient>();
private readonly operationContext = new AsyncLocalStorage<{ readonly signal?: AbortSignal }>();
private memoryIndexFallbackWarned = false;
private readonly operationContext = new AsyncLocalStorage<{
readonly signal?: AbortSignal;
readonly activatedSkills?: ReadonlyArray<ActivatedSkillGuidance>;
}>();
constructor(config: PipelineConfig) {
this.config = config;
@@ -454,9 +469,24 @@ export class PipelineRunner {
signal: AbortSignal | undefined,
task: () => Promise<T>,
): Promise<T> {
signal?.throwIfAborted();
return this.operationContext.run({ signal }, async () => {
signal?.throwIfAborted();
return this.runWithAgentContext({ signal }, task);
}
async runWithAgentContext<T>(
context: {
readonly signal?: AbortSignal;
readonly activatedSkills?: ReadonlyArray<ActivatedSkillGuidance>;
},
task: () => Promise<T>,
): Promise<T> {
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<ActivatedSkillGuidance> | 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<ChapterPipelineResult> {
async writeNextChapter(
bookId: string,
wordCount?: number,
temperatureOverride?: number,
externalContext?: string,
): Promise<ChapterPipelineResult> {
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<Pick<WriteChapterInput, "externalContext" | "chapterIntent" | "chapterMemo" | "chapterIntentData" | "contextPackage" | "ruleStack">> {
): Promise<Pick<WriteChapterInput, "externalContext" | "chapterIntent" | "chapterMemo" | "chapterIntentData" | "contextPackage" | "ruleStack"> & {
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<void> {
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<T>(operation: () => Promise<T> | T): Promise<T> {
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,
+284
View File
@@ -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<Record<string, unknown>>;
}
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<SearchDocument>): 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<string>;
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<Record<string, unknown>> {
try {
const parsed = JSON.parse(value) as unknown;
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed as Readonly<Record<string, unknown>>
: {};
} catch {
return {};
}
}
@@ -91,6 +91,7 @@ export function buildGovernedTrace(params: {
readonly notes?: ReadonlyArray<string>;
readonly promptPacks?: ReadonlyArray<string>;
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 ?? [],
});
}
+235 -252
View File
@@ -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<StoredHook>;
readonly facts: ReadonlyArray<Fact>;
readonly volumeSummaries: ReadonlyArray<VolumeSummarySelection>;
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<string>;
}
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<ReadonlyArray<string>>;
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<string>;
readonly semanticSelector?: MemorySemanticSelector;
}): Promise<MemorySelection> {
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<SearchHit>;
}): Promise<ReadonlyArray<string> | 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>): 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<T>(
path: string,
schema: { parse(value: unknown): T },
@@ -246,90 +279,81 @@ async function readStructuredState<T>(
}
}
function buildLegacyQueryTerms(goal: string, outlineNode: string | undefined, mustKeep: ReadonlyArray<string>): 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<StoredSummary>;
readonly hooks: ReadonlyArray<StoredHook>;
readonly facts: ReadonlyArray<Fact>;
readonly volumeSummaries: ReadonlyArray<VolumeSummarySelection>;
}): 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<SearchHit>): ReadonlyMap<string, number> {
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<string>();
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>): string[] {
const result: string[] = [];
const seen = new Set<string>();
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<StoredSummary>,
chapterNumber: number,
queryTerms: ReadonlyArray<string>,
rankScores: ReadonlyMap<string, number>,
): 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<StoredHook>,
queryTerms: ReadonlyArray<string>,
rankScores: ReadonlyMap<string, number>,
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<Fact>,
queryTerms: ReadonlyArray<string>,
rankScores: ReadonlyMap<string, number>,
): 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<VolumeSummarySelection>,
queryTerms: ReadonlyArray<string>,
rankScores: ReadonlyMap<string, number>,
): 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<string>): 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<string>,
_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<string>): 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()
+8
View File
@@ -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;