feat: improve governed memory retrieval

This commit is contained in:
Ma 2026-03-23 09:45:08 +08:00
parent a5246cec02
commit 3a3db03f5d
30 changed files with 2016 additions and 78 deletions

View file

@ -171,6 +171,47 @@ describe("CLI integration", () => {
const { exitCode, stderr } = runStderr(["status", "nonexistent"]);
expect(exitCode).not.toBe(0);
});
it("shows English chapter counts in words for chapter rows", async () => {
const bookDir = join(projectDir, "books", "english-status");
await mkdir(join(bookDir, "chapters"), { recursive: true });
await writeFile(
join(bookDir, "book.json"),
JSON.stringify({
id: "english-status",
title: "English Status Book",
platform: "other",
genre: "other",
status: "active",
targetChapters: 10,
chapterWordCount: 2200,
language: "en",
createdAt: "2026-03-22T00:00:00.000Z",
updatedAt: "2026-03-22T00:00:00.000Z",
}, null, 2),
"utf-8",
);
await writeFile(
join(bookDir, "chapters", "index.json"),
JSON.stringify([
{
number: 1,
title: "A Quiet Sky",
status: "ready-for-review",
wordCount: 7,
createdAt: "2026-03-22T00:00:00.000Z",
updatedAt: "2026-03-22T00:00:00.000Z",
auditIssues: [],
lengthWarnings: [],
},
], null, 2),
"utf-8",
);
const output = run(["status", "english-status", "--chapters"]);
expect(output).toContain('Ch.1 "A Quiet Sky" | 7 words | ready-for-review');
expect(output).not.toContain("7字");
});
});
describe("inkos doctor", () => {

View file

@ -1,5 +1,5 @@
import { Command } from "commander";
import { StateManager } from "@actalk/inkos-core";
import { StateManager, formatLengthCount, readGenreProfile, resolveLengthCountingMode } from "@actalk/inkos-core";
import { findProjectRoot, resolveBookId, log, logError } from "../utils.js";
export const reviewCommand = new Command("review")
@ -36,6 +36,8 @@ reviewCommand
if (pending.length === 0) continue;
const book = await state.loadBookConfig(id);
const { profile: genreProfile } = await readGenreProfile(root, book.genre);
const countingMode = resolveLengthCountingMode(book.language ?? genreProfile.language);
if (!opts.json) {
log(`\n${book.title} (${id}):`);
@ -52,7 +54,7 @@ reviewCommand
});
if (!opts.json) {
log(
` Ch.${ch.number} "${ch.title}" | ${ch.wordCount} | ${ch.status}`,
` Ch.${ch.number} "${ch.title}" | ${formatLengthCount(ch.wordCount, countingMode)} | ${ch.status}`,
);
if (ch.auditIssues.length > 0) {
for (const issue of ch.auditIssues) {

View file

@ -1,5 +1,5 @@
import { Command } from "commander";
import { StateManager } from "@actalk/inkos-core";
import { StateManager, formatLengthCount, readGenreProfile, resolveLengthCountingMode } from "@actalk/inkos-core";
import { findProjectRoot, log, logError } from "../utils.js";
export const statusCommand = new Command("status")
@ -33,6 +33,8 @@ export const statusCommand = new Command("status")
const book = await state.loadBookConfig(id);
const index = await state.loadChapterIndex(id);
const nextChapter = await state.getNextChapterNumber(id);
const { profile: genreProfile } = await readGenreProfile(root, book.genre);
const countingMode = resolveLengthCountingMode(book.language ?? genreProfile.language);
const approved = index.filter((ch) => ch.status === "approved").length;
const pending = index.filter(
@ -80,7 +82,7 @@ export const statusCommand = new Command("status")
log("");
for (const ch of index) {
const icon = ch.status === "approved" ? "+" : ch.status === "audit-failed" ? "!" : "~";
log(` [${icon}] Ch.${ch.number} "${ch.title}" | ${ch.wordCount} | ${ch.status}`);
log(` [${icon}] Ch.${ch.number} "${ch.title}" | ${formatLengthCount(ch.wordCount, countingMode)} | ${ch.status}`);
if (ch.status === "audit-failed" && ch.auditIssues.length > 0) {
const criticals = ch.auditIssues.filter((i: string) => i.startsWith("[critical]"));
const warnings = ch.auditIssues.filter((i: string) => i.startsWith("[warning]"));

View file

@ -0,0 +1,104 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ChapterAnalyzerAgent } from "../agents/chapter-analyzer.js";
import type { BookConfig } from "../models/book.js";
import { countChapterLength } from "../utils/length-metrics.js";
const ZERO_USAGE = {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
} as const;
describe("ChapterAnalyzerAgent", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("counts English chapter content using words instead of characters", async () => {
const bookDir = await mkdtemp(join(tmpdir(), "inkos-chapter-analyzer-"));
const englishContent = "He looked at the sky and waited.";
const agent = new ChapterAnalyzerAgent({
client: {
provider: "openai",
apiFormat: "chat",
stream: false,
defaults: {
temperature: 0.7,
maxTokens: 4096,
thinkingBudget: 0,
extra: {},
},
},
model: "test-model",
projectRoot: process.cwd(),
});
const book: BookConfig = {
id: "english-book",
title: "English Book",
platform: "other",
genre: "other",
status: "active",
targetChapters: 10,
chapterWordCount: 2200,
language: "en",
createdAt: "2026-03-22T00:00:00.000Z",
updatedAt: "2026-03-22T00:00:00.000Z",
};
vi.spyOn(agent as unknown as { chat: (...args: unknown[]) => Promise<unknown> }, "chat")
.mockResolvedValue({
content: [
"=== CHAPTER_TITLE ===",
"A Quiet Sky",
"",
"=== CHAPTER_CONTENT ===",
englishContent,
"",
"=== PRE_WRITE_CHECK ===",
"",
"=== POST_SETTLEMENT ===",
"",
"=== UPDATED_STATE ===",
"| Field | Value |",
"| --- | --- |",
"| Chapter | 1 |",
"",
"=== UPDATED_LEDGER ===",
"",
"=== UPDATED_HOOKS ===",
"| hook_id | status |",
"| --- | --- |",
"| h1 | open |",
"",
"=== CHAPTER_SUMMARY ===",
"| 1 | A Quiet Sky |",
"",
"=== UPDATED_SUBPLOTS ===",
"",
"=== UPDATED_EMOTIONAL_ARCS ===",
"",
"=== UPDATED_CHARACTER_MATRIX ===",
"",
].join("\n"),
usage: ZERO_USAGE,
});
try {
const output = await agent.analyzeChapter({
book,
bookDir,
chapterNumber: 1,
chapterContent: englishContent,
});
expect(output.wordCount).toBe(countChapterLength(englishContent, "en_words"));
expect(output.wordCount).toBe(7);
} finally {
await rm(bookDir, { recursive: true, force: true });
}
});
});

View file

@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { BookConfig } from "../models/book.js";
@ -93,14 +93,15 @@ describe("ComposerAgent", () => {
plan,
});
expect(result.contextPackage.selectedContext.map((entry) => entry.source)).toEqual([
const selectedSources = result.contextPackage.selectedContext.map((entry) => entry.source);
expect(selectedSources.slice(0, 4)).toEqual([
"story/current_focus.md",
"story/current_state.md",
"story/story_bible.md",
"story/volume_outline.md",
"story/pending_hooks.md",
]);
expect(result.contextPackage.selectedContext.map((entry) => entry.source)).not.toContain("story/style_guide.md");
expect(selectedSources.some((source) => source.startsWith("story/pending_hooks.md"))).toBe(true);
expect(selectedSources).not.toContain("story/style_guide.md");
await expect(readFile(result.contextPath, "utf-8")).resolves.toContain("current_focus.md");
});
@ -145,4 +146,114 @@ describe("ComposerAgent", () => {
expect(result.trace.notes).toContain("allow local outline deferral");
await expect(readFile(result.tracePath, "utf-8")).resolves.toContain("allow local outline deferral");
});
it("retrieves summary and hook evidence chunks instead of whole long memory files", async () => {
await Promise.all([
writeFile(
join(storyDir, "pending_hooks.md"),
[
"# Pending Hooks",
"",
"| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
"| --- | --- | --- | --- | --- | --- | --- |",
"| guild-route | 1 | mystery | open | 2 | 6 | Merchant guild trail |",
"| mentor-oath | 8 | relationship | open | 9 | 11 | Mentor oath debt with Lin Yue |",
"| old-seal | 3 | artifact | resolved | 3 | 3 | Jade seal already recovered |",
"",
].join("\n"),
"utf-8",
),
writeFile(
join(storyDir, "chapter_summaries.md"),
[
"# Chapter Summaries",
"",
"| 1 | Guild Trail | Merchant guild flees west | Route clues only | None | guild-route seeded | tense | action |",
"| 7 | Broken Letter | Lin Yue | A torn letter mentions the mentor | Lin Yue reopens the old oath | mentor-oath seeded | uneasy | mystery |",
"| 8 | River Camp | Lin Yue, Mentor Witness | Mentor debt becomes personal | Lin Yue cannot let go | mentor-oath advanced | raw | confrontation |",
"| 9 | Trial Echo | Lin Yue | Mentor left without explanation | Oath token matters again | mentor-oath advanced | aching | fallout |",
"",
].join("\n"),
"utf-8",
),
]);
const longRangePlan: PlanChapterOutput = {
...plan,
intent: {
...plan.intent,
chapter: 10,
goal: "Bring the focus back to the mentor oath conflict.",
outlineNode: "Track the merchant guild trail.",
},
};
const composer = new ComposerAgent({
client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
model: "test-model",
projectRoot: root,
bookId: book.id,
});
const result = await composer.composeChapter({
book,
bookDir,
chapterNumber: 10,
plan: longRangePlan,
});
const selectedSources = result.contextPackage.selectedContext.map((entry) => entry.source);
expect(selectedSources).toContain("story/pending_hooks.md#mentor-oath");
expect(selectedSources).toContain("story/chapter_summaries.md#9");
expect(selectedSources).not.toContain("story/pending_hooks.md");
await expect(stat(join(storyDir, "memory.db"))).resolves.toBeTruthy();
});
it("adds current-state fact evidence retrieved from sqlite-backed memory", async () => {
await writeFile(
join(storyDir, "current_state.md"),
[
"# Current State",
"",
"| Field | Value |",
"| --- | --- |",
"| Current Chapter | 9 |",
"| Current Location | Ashen ferry crossing |",
"| Protagonist State | Lin Yue hides the broken oath token and the old wound has reopened. |",
"| Current Goal | Find the vanished mentor before the guild covers its tracks. |",
"| Current Conflict | Mentor debt with the vanished teacher blocks every choice. |",
"",
].join("\n"),
"utf-8",
);
const composer = new ComposerAgent({
client: {} as ConstructorParameters<typeof ComposerAgent>[0]["client"],
model: "test-model",
projectRoot: root,
bookId: book.id,
});
const result = await composer.composeChapter({
book,
bookDir,
chapterNumber: 10,
plan: {
...plan,
intent: {
...plan.intent,
chapter: 10,
goal: "Bring the focus back to the vanished mentor conflict.",
},
},
});
const factEntry = result.contextPackage.selectedContext.find((entry) =>
entry.source === "story/current_state.md#current-conflict",
);
expect(factEntry).toBeDefined();
expect(factEntry?.excerpt).toContain("Current Conflict");
expect(factEntry?.excerpt).toContain("Mentor debt with the vanished teacher");
});
});

View file

@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { filterSummaries } from "../utils/context-filter.js";
describe("context-filter", () => {
it("filters old chapter summary rows even when titles start with 'Chapter'", () => {
const summaries = [
"# Chapter Summaries",
"",
"| 1 | Chapter 1 | Lin Yue | Old event | state-1 | side-quest-1 | tense | drama |",
"| 97 | Chapter 97 | Lin Yue | Recent event | state-97 | side-quest-97 | tense | drama |",
"| 100 | Chapter 100 | Lin Yue | Latest event | state-100 | mentor-oath advanced | tense | drama |",
].join("\n");
const filtered = filterSummaries(summaries, 101);
expect(filtered).not.toContain("| 1 | Chapter 1 |");
expect(filtered).toContain("| 97 | Chapter 97 |");
expect(filtered).toContain("| 100 | Chapter 100 |");
});
});

View file

@ -0,0 +1,131 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ContinuityAuditor } from "../agents/continuity.js";
const ZERO_USAGE = {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
} as const;
describe("ContinuityAuditor", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("uses selected summary and hook evidence instead of full long-history markdown in governed mode", async () => {
const root = await mkdtemp(join(tmpdir(), "inkos-auditor-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"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
writeFile(
join(storyDir, "pending_hooks.md"),
[
"# Pending Hooks",
"",
"| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
"| --- | --- | --- | --- | --- | --- | --- |",
"| guild-route | 1 | mystery | open | 2 | 6 | Merchant guild trail |",
"| mentor-oath | 8 | relationship | open | 99 | 101 | Mentor oath debt with Lin Yue |",
"",
].join("\n"),
"utf-8",
),
writeFile(
join(storyDir, "chapter_summaries.md"),
[
"# Chapter Summaries",
"",
"| 1 | Guild Trail | Merchant guild flees west | Route clues only | None | guild-route seeded | tense | action |",
"| 99 | Trial Echo | Lin Yue | Mentor left without explanation | Oath token matters again | mentor-oath advanced | aching | fallout |",
"",
].join("\n"),
"utf-8",
),
writeFile(join(storyDir, "subplot_board.md"), "# 支线进度板\n", "utf-8"),
writeFile(join(storyDir, "emotional_arcs.md"), "# 情感弧线\n", "utf-8"),
writeFile(join(storyDir, "character_matrix.md"), "# 角色交互矩阵\n", "utf-8"),
writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 100\nTrack the merchant guild trail.\n", "utf-8"),
writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
]);
const auditor = new ContinuityAuditor({
client: {
provider: "openai",
apiFormat: "chat",
stream: false,
defaults: {
temperature: 0.7,
maxTokens: 4096,
thinkingBudget: 0,
extra: {},
},
},
model: "test-model",
projectRoot: root,
});
const chatSpy = vi.spyOn(ContinuityAuditor.prototype as never, "chat" as never).mockResolvedValue({
content: JSON.stringify({
passed: true,
issues: [],
summary: "ok",
}),
usage: ZERO_USAGE,
});
try {
await auditor.auditChapter(
bookDir,
"Chapter body.",
100,
"xuanhuan",
{
chapterIntent: "# Chapter Intent\n\n## Goal\nBring the focus back to the mentor oath conflict.\n",
contextPackage: {
chapter: 100,
selectedContext: [
{
source: "story/chapter_summaries.md#99",
reason: "Relevant episodic memory.",
excerpt: "Trial Echo | Mentor left without explanation | mentor-oath advanced",
},
{
source: "story/pending_hooks.md#mentor-oath",
reason: "Carry forward unresolved hook.",
excerpt: "relationship | open | 101 | Mentor oath debt with Lin Yue",
},
],
},
ruleStack: {
layers: [{ id: "L4", name: "current_task", precedence: 70, scope: "local" }],
sections: {
hard: ["current_state"],
soft: ["current_focus"],
diagnostic: ["continuity_audit"],
},
overrideEdges: [],
activeOverrides: [],
},
},
);
const messages = chatSpy.mock.calls[0]?.[0] as
| ReadonlyArray<{ content: string }>
| undefined;
const userPrompt = messages?.[1]?.content ?? "";
expect(userPrompt).toContain("story/chapter_summaries.md#99");
expect(userPrompt).toContain("story/pending_hooks.md#mentor-oath");
expect(userPrompt).not.toContain("| 1 | Guild Trail |");
expect(userPrompt).not.toContain("guild-route | 1 | mystery");
} finally {
await rm(root, { recursive: true, force: true });
}
});
});

View file

@ -194,4 +194,58 @@ describe("LengthNormalizerAgent", () => {
expect(result.normalizedContent).toBe(draft);
expect(result.finalCount).toBe(countChapterLength(draft, "zh_chars"));
});
it("preserves legitimate Chinese prose that starts with '我先'", async () => {
const agent = createAgent();
const prose = "我先回去了,明天再说。\n风从窗缝里灌进来。";
const chatSpy = vi.spyOn(BaseAgent.prototype as never, "chat").mockResolvedValue({
content: prose,
usage: ZERO_USAGE,
});
const lengthSpec = LengthSpecSchema.parse({
target: 220,
softMin: 190,
softMax: 250,
hardMin: 160,
hardMax: 280,
countingMode: "zh_chars",
normalizeMode: "compress",
});
const draft = "原文。".repeat(80);
const result = await agent.normalizeChapter({
chapterContent: draft,
lengthSpec,
});
expect(chatSpy).toHaveBeenCalledTimes(1);
expect(result.normalizedContent).toBe(prose);
});
it("preserves legitimate English prose that starts with 'I will'", async () => {
const agent = createAgent();
const prose = "I will wait here until dawn.\nThe shutters rattled in the wind.";
const chatSpy = vi.spyOn(BaseAgent.prototype as never, "chat").mockResolvedValue({
content: prose,
usage: ZERO_USAGE,
});
const lengthSpec = LengthSpecSchema.parse({
target: 220,
softMin: 190,
softMax: 250,
hardMin: 160,
hardMax: 280,
countingMode: "en_words",
normalizeMode: "compress",
});
const draft = "Original text. ".repeat(80);
const result = await agent.normalizeChapter({
chapterContent: draft,
lengthSpec,
});
expect(chatSpy).toHaveBeenCalledTimes(1);
expect(result.normalizedContent).toBe(prose);
});
});

View file

@ -0,0 +1,64 @@
import { afterEach, describe, expect, it } from "vitest";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { retrieveMemorySelection } from "../utils/memory-retrieval.js";
describe("retrieveMemorySelection", () => {
let root = "";
afterEach(async () => {
if (root) {
await rm(root, { recursive: true, force: true });
root = "";
}
});
it("indexes current state facts into sqlite-backed memory selection", async () => {
root = await mkdtemp(join(tmpdir(), "inkos-memory-retrieval-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"),
[
"# Current State",
"",
"| Field | Value |",
"| --- | --- |",
"| Current Chapter | 9 |",
"| Current Location | Ashen ferry crossing |",
"| Protagonist State | Lin Yue hides the broken oath token and the old wound has reopened. |",
"| Current Goal | Find the vanished mentor before the guild covers its tracks. |",
"| Current Conflict | Mentor debt with the vanished teacher blocks every choice. |",
"",
].join("\n"),
"utf-8",
),
writeFile(join(storyDir, "chapter_summaries.md"), "# Chapter Summaries\n", "utf-8"),
writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
]);
const result = await retrieveMemorySelection({
bookDir,
chapterNumber: 10,
goal: "Bring the focus back to the vanished mentor conflict.",
mustKeep: ["Lin Yue hides the broken oath token and the old wound has reopened."],
});
expect(result.facts.length).toBeGreaterThan(0);
expect(result.facts).toEqual(
expect.arrayContaining([
expect.objectContaining({
predicate: "Current Conflict",
object: "Mentor debt with the vanished teacher blocks every choice.",
validFromChapter: 9,
sourceChapter: 9,
}),
]),
);
expect(result.dbPath).toContain("memory.db");
});
});

View file

@ -14,6 +14,7 @@ import { ReviserAgent, type ReviseOutput } from "../agents/reviser.js";
import { ChapterAnalyzerAgent } from "../agents/chapter-analyzer.js";
import type { BookConfig } from "../models/book.js";
import type { ChapterMeta } from "../models/chapter.js";
import { MemoryDB } from "../state/memory-db.js";
import { countChapterLength } from "../utils/length-metrics.js";
const ZERO_USAGE = {
@ -89,6 +90,29 @@ function createAnalyzedOutput(overrides: Partial<WriteChapterOutput> = {}): Writ
});
}
function createStateCard(params: {
readonly chapter: number;
readonly location: string;
readonly protagonistState: string;
readonly goal: string;
readonly conflict: string;
}): string {
return [
"# Current State",
"",
"| Field | Value |",
"| --- | --- |",
`| Current Chapter | ${params.chapter} |`,
`| Current Location | ${params.location} |`,
`| Protagonist State | ${params.protagonistState} |`,
`| Current Goal | ${params.goal} |`,
"| Current Constraint | The city gates are watched. |",
"| Current Alliances | Mentor allies are scattered. |",
`| Current Conflict | ${params.conflict} |`,
"",
].join("\n");
}
async function createRunnerFixture(
configOverrides: Partial<ConstructorParameters<typeof PipelineRunner>[0]> = {},
): Promise<{
@ -348,6 +372,65 @@ describe("PipelineRunner", () => {
}
});
it("syncs current-state facts into memory.db after drafting a chapter", async () => {
const { root, runner, state, bookId } = await createRunnerFixture();
const chapterOneState = createStateCard({
chapter: 1,
location: "Ashen ferry crossing",
protagonistState: "Lin Yue hides the broken oath token.",
goal: "Find the vanished mentor before dawn.",
conflict: "Mentor debt blocks every choice.",
});
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",
),
]);
await state.snapshotState(bookId, 0);
vi.spyOn(WriterAgent.prototype, "writeChapter").mockResolvedValue(
createWriterOutput({
chapterNumber: 1,
content: "Draft body.",
wordCount: "Draft body.".length,
updatedState: chapterOneState,
updatedHooks: "# Pending Hooks\n",
}),
);
try {
await runner.writeDraft(bookId);
const memoryDb = new MemoryDB(state.bookDir(bookId));
try {
expect(memoryDb.getCurrentFacts()).toEqual(
expect.arrayContaining([
expect.objectContaining({
predicate: "Current Conflict",
object: "Mentor debt blocks every choice.",
validFromChapter: 1,
sourceChapter: 1,
}),
]),
);
} finally {
memoryDb.close();
}
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("routes writeNextChapter through planner and composer in v2 mode", async () => {
const { root, runner, state, bookId } = await createRunnerFixture({
inputGovernanceMode: "v2",
@ -905,4 +988,233 @@ describe("PipelineRunner", () => {
await rm(root, { recursive: true, force: true });
});
it("rebuilds fact history from imported chapter snapshots", async () => {
const { root, runner, state, bookId } = await createRunnerFixture();
vi.spyOn(ArchitectAgent.prototype, "generateFoundationFromImport").mockResolvedValue({
storyBible: "# Story Bible\n",
volumeOutline: "# Volume Outline\n",
bookRules: "---\nversion: \"1.0\"\n---\n\n# Book Rules\n",
currentState: 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.",
}),
pendingHooks: "# Pending Hooks\n",
});
vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter")
.mockResolvedValueOnce(createAnalyzedOutput({
chapterNumber: 1,
title: "One",
content: "One body.",
wordCount: "One body.".length,
updatedState: createStateCard({
chapter: 1,
location: "Ashen ferry crossing",
protagonistState: "Lin Yue still hides the oath token.",
goal: "Find the vanished mentor.",
conflict: "The mentor debt is still personal.",
}),
updatedHooks: "# Pending Hooks\n",
}))
.mockResolvedValueOnce(createAnalyzedOutput({
chapterNumber: 2,
title: "Two",
content: "Two body.",
wordCount: "Two body.".length,
updatedState: createStateCard({
chapter: 2,
location: "North watchtower",
protagonistState: "Lin Yue finally shows the oath token.",
goal: "Reach the watchtower before the guild.",
conflict: "The merchant guild now contests the mentor trail.",
}),
updatedHooks: "# Pending Hooks\n",
}));
try {
await runner.importChapters({
bookId,
chapters: [
{ title: "One", content: "One body." },
{ title: "Two", content: "Two body." },
],
});
const memoryDb = new MemoryDB(state.bookDir(bookId));
try {
const chapterOneFacts = memoryDb.getFactsAt("protagonist", 1);
const currentFacts = memoryDb.getCurrentFacts();
expect(chapterOneFacts).toEqual(
expect.arrayContaining([
expect.objectContaining({
predicate: "Current Conflict",
object: "The mentor debt is still personal.",
}),
]),
);
expect(currentFacts).toEqual(
expect.arrayContaining([
expect.objectContaining({
predicate: "Current Conflict",
object: "The merchant guild now contests the mentor trail.",
validFromChapter: 2,
sourceChapter: 2,
}),
]),
);
} finally {
memoryDb.close();
}
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("tracks imported English chapters using word counts instead of characters", async () => {
const { root, runner, state, bookId } = await createRunnerFixture();
const englishBook = {
...(await state.loadBookConfig(bookId)),
genre: "other",
language: "en" as const,
};
const now = "2026-03-19T00:00:00.000Z";
await state.saveBookConfig(bookId, englishBook);
await state.saveChapterIndex(bookId, [
{
number: 1,
title: "Prelude",
status: "imported",
wordCount: 3,
createdAt: now,
updatedAt: now,
auditIssues: [],
lengthWarnings: [],
},
{
number: 2,
title: "Crossroads",
status: "imported",
wordCount: 2,
createdAt: now,
updatedAt: now,
auditIssues: [],
lengthWarnings: [],
},
]);
vi.spyOn(ChapterAnalyzerAgent.prototype, "analyzeChapter").mockImplementation(async (input) =>
createAnalyzedOutput({
chapterNumber: input.chapterNumber,
title: input.chapterTitle ?? `Chapter ${input.chapterNumber}`,
content: input.chapterContent,
wordCount: countChapterLength(input.chapterContent, "en_words"),
}),
);
vi.spyOn(WriterAgent.prototype, "saveChapter").mockResolvedValue(undefined);
vi.spyOn(WriterAgent.prototype, "saveNewTruthFiles").mockResolvedValue(undefined);
const result = await runner.importChapters({
bookId,
resumeFrom: 3,
chapters: [
{ title: "Prelude", content: "One two three" },
{ title: "Crossroads", content: "Four five" },
{ title: "The Watchtower", content: "The storm kept rolling west" },
{ title: "Aftermath", content: "Lanterns dimmed before dawn broke" },
],
});
const chapterIndex = await state.loadChapterIndex(bookId);
const chapterThree = chapterIndex.find((entry) => entry.number === 3);
const chapterFour = chapterIndex.find((entry) => entry.number === 4);
expect(result.importedCount).toBe(2);
expect(result.totalWords).toBe(10);
expect(chapterThree?.wordCount).toBe(5);
expect(chapterFour?.wordCount).toBe(5);
await rm(root, { recursive: true, force: true });
});
it("rebuilds current facts from the revised chapter snapshot", async () => {
const { root, runner, state, bookId } = await createRunnerFixture();
const storyDir = join(state.bookDir(bookId), "story");
const chaptersDir = join(state.bookDir(bookId), "chapters");
const oldState = createStateCard({
chapter: 1,
location: "Ashen ferry crossing",
protagonistState: "Lin Yue still hides the oath token.",
goal: "Find the vanished mentor.",
conflict: "The mentor debt is still personal.",
});
const revisedState = createStateCard({
chapter: 1,
location: "Ashen ferry crossing",
protagonistState: "Lin Yue no longer hides the oath token.",
goal: "Confront the vanished mentor.",
conflict: "The oath token is public now, forcing the confrontation.",
});
await Promise.all([
writeFile(join(chaptersDir, "0001_Test_Chapter.md"), "# 第1章 Test Chapter\n\nOriginal body.", "utf-8"),
writeFile(join(storyDir, "current_state.md"), oldState, "utf-8"),
writeFile(join(storyDir, "pending_hooks.md"), "# Pending Hooks\n", "utf-8"),
]);
await state.saveChapterIndex(bookId, [{
number: 1,
title: "Test Chapter",
status: "audit-failed",
wordCount: "Original body.".length,
createdAt: "2026-03-19T00:00:00.000Z",
updatedAt: "2026-03-19T00:00:00.000Z",
auditIssues: [],
lengthWarnings: [],
}]);
await state.snapshotState(bookId, 1);
vi.spyOn(ContinuityAuditor.prototype, "auditChapter").mockResolvedValue(
createAuditResult({
passed: false,
issues: [CRITICAL_ISSUE],
summary: "needs revision",
}),
);
vi.spyOn(ReviserAgent.prototype, "reviseChapter").mockResolvedValue(
createReviseOutput({
revisedContent: "Revised body.",
wordCount: "Revised body.".length,
updatedState: revisedState,
updatedHooks: "# Pending Hooks\n",
}),
);
try {
await runner.reviseDraft(bookId, 1);
const memoryDb = new MemoryDB(state.bookDir(bookId));
try {
expect(memoryDb.getCurrentFacts()).toEqual(
expect.arrayContaining([
expect.objectContaining({
predicate: "Current Conflict",
object: "The oath token is public now, forcing the confrontation.",
validFromChapter: 1,
sourceChapter: 1,
}),
]),
);
} finally {
memoryDb.close();
}
} finally {
await rm(root, { recursive: true, force: true });
}
});
});

View file

@ -132,4 +132,62 @@ describe("PlannerAgent", () => {
expect(result.intent.conflicts[0]?.type).toBe("outline_vs_request");
await expect(readFile(result.runtimePath, "utf-8")).resolves.toContain("outline_vs_request");
});
it("writes compact memory snapshots instead of inlining the full history", async () => {
await Promise.all([
writeFile(
join(storyDir, "pending_hooks.md"),
[
"# Pending Hooks",
"",
"| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
"| --- | --- | --- | --- | --- | --- | --- |",
"| guild-route | 1 | mystery | open | 2 | 6 | Merchant guild trail |",
"| mentor-oath | 8 | relationship | open | 9 | 11 | Mentor oath debt with Lin Yue |",
"| old-seal | 3 | artifact | resolved | 3 | 3 | Jade seal already recovered |",
"",
].join("\n"),
"utf-8",
),
writeFile(
join(storyDir, "chapter_summaries.md"),
[
"# Chapter Summaries",
"",
"| 1 | Guild Trail | Merchant guild flees west | Route clues only | None | guild-route seeded | tense | action |",
"| 2 | City Watch | Patrols sweep the market | Search widens | None | guild-route advanced | urgent | investigation |",
"| 3 | Seal Vault | Lin Yue finds the seal vault | The jade seal returns | Seal secured | old-seal resolved | solemn | reveal |",
"| 4 | Empty Road | The group loses the convoy | Doubts grow | Travel fatigue | none | grim | travel |",
"| 5 | Burned Shrine | Shrine clues point nowhere | Friction rises | Lin Yue distrusts allies | none | bitter | setback |",
"| 6 | Quiet Ledger | Merchant records stay hidden | No breakthrough | Cash runs thin | none | weary | transition |",
"| 7 | Broken Letter | A torn letter mentions the mentor | Suspicion returns | Lin Yue reopens the old oath | mentor-oath seeded | uneasy | mystery |",
"| 8 | River Camp | Lin Yue meets old witnesses | Mentor debt becomes personal | Lin Yue cannot let go | mentor-oath advanced | raw | confrontation |",
"| 9 | Trial Echo | The trial fallout resurfaces | Mentor left without explanation | Oath token matters again | mentor-oath advanced | aching | fallout |",
"| 10 | Locked Gate | Lin Yue chooses the mentor line over the guild line | Mentor conflict takes priority | Oath token is still hidden | mentor-oath advanced | focused | decision |",
"",
].join("\n"),
"utf-8",
),
]);
const planner = new PlannerAgent({
client: {} as ConstructorParameters<typeof PlannerAgent>[0]["client"],
model: "test-model",
projectRoot: root,
bookId: book.id,
});
const result = await planner.planChapter({
book,
bookDir,
chapterNumber: 11,
externalContext: "Bring the focus back to the mentor oath conflict with Lin Yue.",
});
const intentMarkdown = await readFile(result.runtimePath, "utf-8");
expect(intentMarkdown).toContain("mentor-oath");
expect(intentMarkdown).toContain("| 10 | Locked Gate |");
expect(intentMarkdown).not.toContain("| 1 | Guild Trail |");
expect(intentMarkdown).not.toContain("| old-seal | 3 | artifact | resolved |");
});
});

View file

@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, mkdir, rm } from "node:fs/promises";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ReviserAgent } from "../agents/reviser.js";
@ -88,4 +88,128 @@ describe("ReviserAgent", () => {
await rm(root, { recursive: true, force: true });
}
});
it("uses selected summary and hook evidence instead of full long-history markdown in governed mode", async () => {
const root = await mkdtemp(join(tmpdir(), "inkos-reviser-governed-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"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
writeFile(
join(storyDir, "pending_hooks.md"),
[
"# Pending Hooks",
"",
"| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
"| --- | --- | --- | --- | --- | --- | --- |",
"| guild-route | 1 | mystery | open | 2 | 6 | Merchant guild trail |",
"| mentor-oath | 8 | relationship | open | 99 | 101 | Mentor oath debt with Lin Yue |",
"",
].join("\n"),
"utf-8",
),
writeFile(
join(storyDir, "chapter_summaries.md"),
[
"# Chapter Summaries",
"",
"| 1 | Guild Trail | Merchant guild flees west | Route clues only | None | guild-route seeded | tense | action |",
"| 99 | Trial Echo | Lin Yue | Mentor left without explanation | Oath token matters again | mentor-oath advanced | aching | fallout |",
"",
].join("\n"),
"utf-8",
),
writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 100\nTrack the merchant guild trail.\n", "utf-8"),
writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
writeFile(join(storyDir, "character_matrix.md"), "# 角色交互矩阵\n", "utf-8"),
writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
]);
const agent = new ReviserAgent({
client: {
provider: "openai",
apiFormat: "chat",
stream: false,
defaults: {
temperature: 0.7,
maxTokens: 4096,
thinkingBudget: 0,
extra: {},
},
},
model: "test-model",
projectRoot: root,
});
const chatSpy = vi.spyOn(ReviserAgent.prototype as never, "chat" as never).mockResolvedValue({
content: [
"=== FIXED_ISSUES ===",
"- repaired",
"",
"=== REVISED_CONTENT ===",
"修订后的正文。",
"",
"=== UPDATED_STATE ===",
"状态卡",
"",
"=== UPDATED_HOOKS ===",
"伏笔池",
].join("\n"),
usage: ZERO_USAGE,
});
try {
await agent.reviseChapter(
bookDir,
"原始正文。",
100,
[CRITICAL_ISSUE],
"spot-fix",
"xuanhuan",
{
chapterIntent: "# Chapter Intent\n\n## Goal\nBring the focus back to the mentor oath conflict.\n",
contextPackage: {
chapter: 100,
selectedContext: [
{
source: "story/chapter_summaries.md#99",
reason: "Relevant episodic memory.",
excerpt: "Trial Echo | Mentor left without explanation | mentor-oath advanced",
},
{
source: "story/pending_hooks.md#mentor-oath",
reason: "Carry forward unresolved hook.",
excerpt: "relationship | open | 101 | Mentor oath debt with Lin Yue",
},
],
},
ruleStack: {
layers: [{ id: "L4", name: "current_task", precedence: 70, scope: "local" }],
sections: {
hard: ["current_state"],
soft: ["current_focus"],
diagnostic: ["continuity_audit"],
},
overrideEdges: [],
activeOverrides: [],
},
lengthSpec: buildLengthSpec(220, "zh"),
},
);
const messages = chatSpy.mock.calls[0]?.[0] as
| ReadonlyArray<{ content: string }>
| undefined;
const userPrompt = messages?.[1]?.content ?? "";
expect(userPrompt).toContain("story/chapter_summaries.md#99");
expect(userPrompt).toContain("story/pending_hooks.md#mentor-oath");
expect(userPrompt).not.toContain("| 1 | Guild Trail |");
expect(userPrompt).not.toContain("guild-route | 1 | mystery");
} finally {
await rm(root, { recursive: true, force: true });
}
});
});

View file

@ -0,0 +1,163 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { WriterAgent } from "../agents/writer.js";
import { buildLengthSpec } from "../utils/length-metrics.js";
const ZERO_USAGE = {
promptTokens: 0,
completionTokens: 0,
totalTokens: 0,
} as const;
describe("WriterAgent", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("uses compact summary context plus selected long-range evidence during governed settlement", async () => {
const root = await mkdtemp(join(tmpdir(), "inkos-writer-test-"));
const bookDir = join(root, "book");
const storyDir = join(bookDir, "story");
await mkdir(storyDir, { recursive: true });
await Promise.all([
writeFile(join(storyDir, "story_bible.md"), "# Story Bible\n\n- The jade seal cannot be destroyed.\n", "utf-8"),
writeFile(join(storyDir, "volume_outline.md"), "# Volume Outline\n\n## Chapter 100\nTrack the merchant guild trail.\n", "utf-8"),
writeFile(join(storyDir, "style_guide.md"), "# Style Guide\n\n- Keep the prose restrained.\n", "utf-8"),
writeFile(join(storyDir, "current_state.md"), "# Current State\n\n- Lin Yue still hides the broken oath token.\n", "utf-8"),
writeFile(join(storyDir, "pending_hooks.md"), [
"# Pending Hooks",
"",
"| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
"| --- | --- | --- | --- | --- | --- | --- |",
"| guild-route | 1 | mystery | open | 2 | 6 | Merchant guild trail |",
"| mentor-oath | 8 | relationship | open | 99 | 101 | Mentor oath debt with Lin Yue |",
].join("\n"), "utf-8"),
writeFile(join(storyDir, "chapter_summaries.md"), [
"# Chapter Summaries",
"",
"| 1 | Guild Trail | Merchant guild flees west | Route clues only | None | guild-route seeded | tense | action |",
"| 97 | Shrine Ash | Lin Yue | The old shrine proves empty | Frustration rises | none | bitter | setback |",
"| 98 | Trial Echo | Lin Yue | Mentor left without explanation | Oath token matters again | mentor-oath advanced | aching | fallout |",
"| 99 | Locked Gate | Lin Yue | Lin Yue chooses the mentor line over the guild line | Mentor conflict takes priority | mentor-oath advanced | focused | decision |",
].join("\n"), "utf-8"),
writeFile(join(storyDir, "subplot_board.md"), "# 支线进度板\n", "utf-8"),
writeFile(join(storyDir, "emotional_arcs.md"), "# 情感弧线\n", "utf-8"),
writeFile(join(storyDir, "character_matrix.md"), "# 角色交互矩阵\n", "utf-8"),
]);
const agent = new WriterAgent({
client: {
provider: "openai",
apiFormat: "chat",
stream: false,
defaults: {
temperature: 0.7,
maxTokens: 4096,
thinkingBudget: 0,
extra: {},
},
},
model: "test-model",
projectRoot: root,
});
const chatSpy = vi.spyOn(WriterAgent.prototype as never, "chat" as never)
.mockResolvedValueOnce({
content: [
"=== CHAPTER_TITLE ===",
"A Decision",
"",
"=== CHAPTER_CONTENT ===",
"Lin Yue turned away from the guild trail and chose the mentor debt.",
"",
"=== PRE_WRITE_CHECK ===",
"- ok",
].join("\n"),
usage: ZERO_USAGE,
})
.mockResolvedValueOnce({
content: "=== OBSERVATIONS ===\n- observed",
usage: ZERO_USAGE,
})
.mockResolvedValueOnce({
content: [
"=== POST_SETTLEMENT ===",
"| 伏笔变动 | mentor-oath 推进 | 同步更新伏笔池 |",
"",
"=== UPDATED_STATE ===",
"状态卡",
"",
"=== UPDATED_HOOKS ===",
"伏笔池",
"",
"=== CHAPTER_SUMMARY ===",
"| 100 | A Decision | Lin Yue | Chooses the mentor debt | Focus narrowed | mentor-oath advanced | tense | decision |",
"",
"=== UPDATED_SUBPLOTS ===",
"支线板",
"",
"=== UPDATED_EMOTIONAL_ARCS ===",
"情感弧线",
"",
"=== UPDATED_CHARACTER_MATRIX ===",
"角色矩阵",
].join("\n"),
usage: ZERO_USAGE,
});
try {
await agent.writeChapter({
book: {
id: "writer-book",
title: "Writer Book",
platform: "tomato",
genre: "xuanhuan",
status: "active",
targetChapters: 120,
chapterWordCount: 2200,
createdAt: "2026-03-23T00:00:00.000Z",
updatedAt: "2026-03-23T00:00:00.000Z",
},
bookDir,
chapterNumber: 100,
chapterIntent: "# Chapter Intent\n\n## Goal\nBring the focus back to the mentor oath conflict.\n",
contextPackage: {
chapter: 100,
selectedContext: [
{
source: "story/chapter_summaries.md#99",
reason: "Relevant episodic memory.",
excerpt: "Locked Gate | Lin Yue chooses the mentor line over the guild line | mentor-oath advanced",
},
{
source: "story/pending_hooks.md#mentor-oath",
reason: "Carry forward unresolved hook.",
excerpt: "relationship | open | 101 | Mentor oath debt with Lin Yue",
},
],
},
ruleStack: {
layers: [{ id: "L4", name: "current_task", precedence: 70, scope: "local" }],
sections: {
hard: ["current_state"],
soft: ["current_focus"],
diagnostic: ["continuity_audit"],
},
overrideEdges: [],
activeOverrides: [],
},
lengthSpec: buildLengthSpec(220, "zh"),
});
const settlePrompt = (chatSpy.mock.calls[2]?.[0] as ReadonlyArray<{ content: string }> | undefined)?.[1]?.content ?? "";
expect(settlePrompt).toContain("story/chapter_summaries.md#99");
expect(settlePrompt).toContain("| 99 | Locked Gate |");
expect(settlePrompt).not.toContain("| 1 | Guild Trail |");
} finally {
await rm(root, { recursive: true, force: true });
}
});
});

View file

@ -3,6 +3,7 @@ import type { BookConfig } from "../models/book.js";
import type { GenreProfile } from "../models/genre-profile.js";
import { readGenreProfile, readBookRules } from "./rules-reader.js";
import { parseWriterOutput, type ParsedWriterOutput } from "./writer-parser.js";
import { resolveLengthCountingMode } from "../utils/length-metrics.js";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
@ -71,7 +72,8 @@ export class ChapterAnalyzerAgent extends BaseAgent {
{ maxTokens: 16384, temperature: 0.3 },
);
const output = parseWriterOutput(chapterNumber, response.content, genreProfile);
const countingMode = resolveLengthCountingMode(book.language ?? genreProfile.language);
const output = parseWriterOutput(chapterNumber, response.content, genreProfile, countingMode);
// If LLM didn't return a title, use the one from input or derive from chapter number
if (output.title === `${chapterNumber}` && chapterTitle) {

View file

@ -1,5 +1,5 @@
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { dirname, join } from "node:path";
import yaml from "js-yaml";
import { BaseAgent } from "./base.js";
import type { BookConfig } from "../models/book.js";
@ -12,6 +12,7 @@ import {
type RuleStack,
} from "../models/input-governance.js";
import type { PlanChapterOutput } from "./planner.js";
import { retrieveMemorySelection } from "../utils/memory-retrieval.js";
export interface ComposeChapterInput {
readonly book: BookConfig;
@ -120,14 +121,43 @@ export class ComposerAgent extends BaseAgent {
"Anchor the default planning node for this chapter.",
plan.intent.outlineNode ? [plan.intent.outlineNode] : [],
),
this.maybeContextSource(
storyDir,
"pending_hooks.md",
"Carry forward unresolved hooks that match the chapter focus.",
),
]);
return entries.filter((entry): entry is NonNullable<typeof entry> => entry !== null);
const planningAnchor = plan.intent.conflicts.length > 0 ? undefined : plan.intent.outlineNode;
const memorySelection = await retrieveMemorySelection({
bookDir: dirname(storyDir),
chapterNumber: plan.intent.chapter,
goal: plan.intent.goal,
outlineNode: planningAnchor,
mustKeep: plan.intent.mustKeep,
});
const summaryEntries = memorySelection.summaries.map((summary) => ({
source: `story/chapter_summaries.md#${summary.chapter}`,
reason: "Relevant episodic memory retrieved for the current chapter goal.",
excerpt: [summary.title, summary.events, summary.stateChanges, summary.hookActivity]
.filter(Boolean)
.join(" | "),
}));
const factEntries = memorySelection.facts.map((fact) => ({
source: `story/current_state.md#${this.toFactAnchor(fact.predicate)}`,
reason: "Relevant current-state fact retrieved for the current chapter goal.",
excerpt: `${fact.predicate} | ${fact.object}`,
}));
const hookEntries = memorySelection.hooks.map((hook) => ({
source: `story/pending_hooks.md#${hook.hookId}`,
reason: "Carry forward unresolved hooks that match the chapter focus.",
excerpt: [hook.type, hook.status, hook.expectedPayoff, hook.notes]
.filter(Boolean)
.join(" | "),
}));
return [
...entries.filter((entry): entry is NonNullable<typeof entry> => entry !== null),
...factEntries,
...summaryEntries,
...hookEntries,
];
}
private async maybeContextSource(
@ -158,6 +188,15 @@ export class ComposerAgent extends BaseAgent {
.find((line) => line.length > 0 && !line.startsWith("#"));
}
private toFactAnchor(predicate: string): string {
return predicate
.trim()
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fff]+/g, "-")
.replace(/^-+|-+$/g, "")
|| "fact";
}
private async readFileOrDefault(path: string): Promise<string> {
try {
return await readFile(path, "utf-8");

View file

@ -7,6 +7,7 @@ import { readGenreProfile, readBookRules } from "./rules-reader.js";
import { getFanficDimensionConfig, FANFIC_DIMENSIONS } from "./fanfic-dimensions.js";
import { readFile, readdir } from "node:fs/promises";
import { filterHooks, filterSummaries, filterSubplots, filterEmotionalArcs, filterCharacterMatrix } from "../utils/context-filter.js";
import { buildGovernedMemoryEvidenceBlocks } from "../utils/governed-context.js";
import { join } from "node:path";
export interface AuditResult {
@ -317,6 +318,14 @@ ${dimList}
const filteredSummaries = filterSummaries(chapterSummaries, chapterNumber);
const filteredHooks = filterHooks(hooks);
const governedMemoryBlocks = options?.contextPackage
? buildGovernedMemoryEvidenceBlocks(options.contextPackage)
: undefined;
const hooksBlock = governedMemoryBlocks?.hooksBlock
?? (filteredHooks !== "(文件不存在)"
? `\n## 伏笔池\n${filteredHooks}\n`
: "");
const subplotBlock = filteredSubplots !== "(文件不存在)"
? `\n## 支线进度板\n${filteredSubplots}\n`
: "";
@ -326,9 +335,10 @@ ${dimList}
const matrixBlock = filteredMatrix !== "(文件不存在)"
? `\n## 角色交互矩阵\n${filteredMatrix}\n`
: "";
const summariesBlock = filteredSummaries !== "(文件不存在)"
? `\n## 章节摘要(用于节奏检查)\n${filteredSummaries}\n`
: "";
const summariesBlock = governedMemoryBlocks?.summariesBlock
?? (filteredSummaries !== "(文件不存在)"
? `\n## 章节摘要(用于节奏检查)\n${filteredSummaries}\n`
: "");
const canonBlock = hasParentCanon
? `\n## 正传正典参照(番外审查专用)\n${parentCanon}\n`
@ -357,9 +367,7 @@ ${dimList}
##
${currentState}
${ledgerBlock}
##
${filteredHooks}
${subplotBlock}${emotionalBlock}${matrixBlock}${summariesBlock}${canonBlock}${fanficCanonBlock}${reducedControlBlock || outlineBlock}${prevChapterBlock}${styleGuideBlock}
${hooksBlock}${subplotBlock}${emotionalBlock}${matrixBlock}${summariesBlock}${canonBlock}${fanficCanonBlock}${reducedControlBlock || outlineBlock}${prevChapterBlock}${styleGuideBlock}
##
${chapterContent}`;

View file

@ -137,8 +137,8 @@ ${input.chapterContent}`;
const fenced = this.extractFirstFencedBlock(trimmed);
if (fenced) return fenced;
if (this.looksLikeResponseWrapper(trimmed)) {
const stripped = this.stripCommonWrappers(trimmed);
const stripped = this.stripCommonWrappers(trimmed);
if (stripped !== undefined) {
return stripped || fallbackContent;
}
@ -152,21 +152,48 @@ ${input.chapterContent}`;
return body ? body : undefined;
}
private looksLikeResponseWrapper(content: string): boolean {
return /```/.test(content) || /^(我先|下面是|以下是|Here is|I will)/i.test(content);
private stripCommonWrappers(content: string): string | undefined {
const lines = content.split("\n");
let removedAny = false;
const keptLines: string[] = [];
for (const rawLine of lines) {
const trimmed = rawLine.trim();
if (this.isWrapperLine(trimmed)) {
removedAny = true;
continue;
}
keptLines.push(rawLine);
}
if (!removedAny) {
return undefined;
}
return keptLines.join("\n").trim();
}
private stripCommonWrappers(content: string): string {
return content
.split("\n")
.map((line) => line.trim())
.filter((line) =>
line.length > 0 &&
!/^```/.test(line) &&
!/^(我先|下面是|以下是|Here is|I will)/i.test(line) &&
!/^#+\s*(说明|解释|注释|analysis|analysis note)/i.test(line),
)
.join("\n")
.trim();
private isWrapperLine(line: string): boolean {
if (!line) return false;
if (/^```/.test(line)) return true;
if (/^#+\s*(说明|解释|注释|analysis|analysis note)\b/i.test(line)) return true;
if (/^(下面是|以下是).*(正文|章节|压缩|扩写|修正|修改|调整|改写|润色|结果|内容|输出|版本)/i.test(line)) {
return true;
}
if (/^我先.*(压缩|扩写|修正|修改|调整|改写|润色|处理).*(正文|章节)?/i.test(line)) {
return true;
}
if (/^(here(?:'s| is)|below is).*(chapter|draft|content|rewrite|revised|compressed|expanded|normalized|adjusted|output|version|result)/i.test(line)) {
return true;
}
if (/^i(?:'ll| will)\s+(rewrite|revise|reword|compress|expand|normalize|adjust|shorten|lengthen|trim|fix)\b/i.test(line)) {
return true;
}
return false;
}
}

View file

@ -4,6 +4,7 @@ import { BaseAgent } from "./base.js";
import type { BookConfig } from "../models/book.js";
import { parseBookRules } from "../models/book-rules.js";
import { ChapterIntentSchema, type ChapterConflict, type ChapterIntent } from "../models/input-governance.js";
import { renderHookSnapshot, renderSummarySnapshot, retrieveMemorySelection } from "../utils/memory-retrieval.js";
export interface PlanChapterInput {
readonly book: BookConfig;
@ -36,8 +37,6 @@ export class PlannerAgent extends BaseAgent {
volumeOutline: join(storyDir, "volume_outline.md"),
bookRules: join(storyDir, "book_rules.md"),
currentState: join(storyDir, "current_state.md"),
pendingHooks: join(storyDir, "pending_hooks.md"),
chapterSummaries: join(storyDir, "chapter_summaries.md"),
} as const;
const [
@ -47,8 +46,6 @@ export class PlannerAgent extends BaseAgent {
volumeOutline,
bookRulesRaw,
currentState,
pendingHooks,
chapterSummaries,
] = await Promise.all([
this.readFileOrDefault(sourcePaths.authorIntent),
this.readFileOrDefault(sourcePaths.currentFocus),
@ -56,8 +53,6 @@ export class PlannerAgent extends BaseAgent {
this.readFileOrDefault(sourcePaths.volumeOutline),
this.readFileOrDefault(sourcePaths.bookRules),
this.readFileOrDefault(sourcePaths.currentState),
this.readFileOrDefault(sourcePaths.pendingHooks),
this.readFileOrDefault(sourcePaths.chapterSummaries),
]);
const goal = this.deriveGoal(input.externalContext, currentFocus, authorIntent, input.chapterNumber);
@ -67,6 +62,14 @@ export class PlannerAgent extends BaseAgent {
const mustAvoid = this.collectMustAvoid(currentFocus, parsedRules.rules.prohibitions);
const styleEmphasis = this.collectStyleEmphasis(authorIntent, currentFocus);
const conflicts = this.collectConflicts(input.externalContext, outlineNode, volumeOutline);
const planningAnchor = conflicts.length > 0 ? undefined : outlineNode;
const memorySelection = await retrieveMemorySelection({
bookDir: input.bookDir,
chapterNumber: input.chapterNumber,
goal,
outlineNode: planningAnchor,
mustKeep,
});
const intent = ChapterIntentSchema.parse({
chapter: input.chapterNumber,
@ -79,13 +82,22 @@ export class PlannerAgent extends BaseAgent {
});
const runtimePath = join(runtimeDir, `chapter-${String(input.chapterNumber).padStart(4, "0")}.intent.md`);
const intentMarkdown = this.renderIntentMarkdown(intent, pendingHooks, chapterSummaries);
const intentMarkdown = this.renderIntentMarkdown(
intent,
renderHookSnapshot(memorySelection.hooks),
renderSummarySnapshot(memorySelection.summaries),
);
await writeFile(runtimePath, intentMarkdown, "utf-8");
return {
intent,
intentMarkdown,
plannerInputs: Object.values(sourcePaths),
plannerInputs: [
...Object.values(sourcePaths),
join(storyDir, "pending_hooks.md"),
join(storyDir, "chapter_summaries.md"),
...(memorySelection.dbPath ? [memorySelection.dbPath] : []),
],
runtimePath,
};
}

View file

@ -6,6 +6,7 @@ import type { AuditIssue } from "./continuity.js";
import type { ContextPackage, RuleStack } from "../models/input-governance.js";
import { readGenreProfile, readBookRules } from "./rules-reader.js";
import { countChapterLength } from "../utils/length-metrics.js";
import { buildGovernedMemoryEvidenceBlocks } from "../utils/governed-context.js";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
@ -137,6 +138,11 @@ ${gp.numericalSystem ? "\n=== UPDATED_LEDGER ===\n(更新后的完整资源账
const ledgerBlock = gp.numericalSystem
? `\n## 资源账本\n${ledger}`
: "";
const governedMemoryBlocks = options?.contextPackage
? buildGovernedMemoryEvidenceBlocks(options.contextPackage)
: undefined;
const hooksBlock = governedMemoryBlocks?.hooksBlock
?? `\n## 伏笔池\n${hooks}\n`;
const outlineBlock = volumeOutline !== "(文件不存在)"
? `\n## 卷纲\n${volumeOutline}\n`
: "";
@ -146,9 +152,10 @@ ${gp.numericalSystem ? "\n=== UPDATED_LEDGER ===\n(更新后的完整资源账
const matrixBlock = characterMatrix !== "(文件不存在)"
? `\n## 角色交互矩阵\n${characterMatrix}\n`
: "";
const summariesBlock = chapterSummaries !== "(文件不存在)"
? `\n## 章节摘要\n${chapterSummaries}\n`
: "";
const summariesBlock = governedMemoryBlocks?.summariesBlock
?? (chapterSummaries !== "(文件不存在)"
? `\n## 章节摘要\n${chapterSummaries}\n`
: "");
const hasParentCanon = parentCanon !== "(文件不存在)";
const hasFanficCanon = fanficCanon !== "(文件不存在)";
@ -178,9 +185,7 @@ ${issueList}
##
${currentState}
${ledgerBlock}
##
${hooks}
${reducedControlBlock || outlineBlock}${bibleBlock}${matrixBlock}${summariesBlock}${canonBlock}${fanficCanonBlock}${styleGuideBlock}${lengthGuidanceBlock}
${hooksBlock}${reducedControlBlock || outlineBlock}${bibleBlock}${matrixBlock}${summariesBlock}${canonBlock}${fanficCanonBlock}${styleGuideBlock}${lengthGuidanceBlock}
##
${chapterContent}`;

View file

@ -143,6 +143,7 @@ export function buildSettlerUserPrompt(params: {
readonly characterMatrix: string;
readonly volumeOutline: string;
readonly observations?: string;
readonly selectedEvidenceBlock?: string;
}): string {
const ledgerBlock = params.ledger
? `\n## 当前资源账本\n${params.ledger}\n`
@ -167,6 +168,9 @@ export function buildSettlerUserPrompt(params: {
const observationsBlock = params.observations
? `\n## 观察日志(由 Observer 提取,包含本章所有事实变化)\n${params.observations}\n\n基于以上观察日志和正文更新所有追踪文件。确保观察日志中的每一项变化都反映在对应的文件中。\n`
: "";
const selectedEvidenceBlock = params.selectedEvidenceBlock
? `\n## 已选长程证据\n${params.selectedEvidenceBlock}\n`
: "";
return `请分析第${params.chapterNumber}章「${params.title}」的正文,更新所有追踪文件。
${observationsBlock}
@ -179,7 +183,7 @@ ${params.currentState}
${ledgerBlock}
##
${params.hooks}
${summariesBlock}${subplotBlock}${emotionalBlock}${matrixBlock}
${selectedEvidenceBlock}${summariesBlock}${subplotBlock}${emotionalBlock}${matrixBlock}
##
${params.volumeOutline}

View file

@ -13,6 +13,7 @@ import type { ChapterTrace, ContextPackage, RuleStack } from "../models/input-go
import type { LengthSpec } from "../models/length-governance.js";
import { buildLengthSpec } from "../utils/length-metrics.js";
import { filterHooks, filterSummaries, filterSubplots, filterEmotionalArcs, filterCharacterMatrix } from "../utils/context-filter.js";
import { buildGovernedMemoryEvidenceBlocks } from "../utils/governed-context.js";
import { extractPOVFromOutline, filterMatrixByPOV, filterHooksByPOV } from "../utils/pov-filter.js";
import { parseCreativeOutput } from "./writer-parser.js";
import { readFile, writeFile, mkdir, readdir } from "node:fs/promises";
@ -106,6 +107,9 @@ export class WriterAgent extends BaseAgent {
const resolvedLanguage = book.language ?? genreProfile.language;
const targetWords = input.lengthSpec?.target ?? input.wordCountOverride ?? book.chapterWordCount;
const resolvedLengthSpec = input.lengthSpec ?? buildLengthSpec(targetWords, resolvedLanguage);
const governedMemoryBlocks = input.contextPackage
? buildGovernedMemoryEvidenceBlocks(input.contextPackage)
: undefined;
// Build fanfic context if fanfic_canon.md exists
const fanficContext: FanficContext | undefined = hasFanficCanon && bookRules?.fanficMode
@ -204,11 +208,16 @@ export class WriterAgent extends BaseAgent {
currentState,
ledger: genreProfile.numericalSystem ? ledger : "",
hooks,
chapterSummaries,
chapterSummaries: input.contextPackage ? filterSummaries(chapterSummaries, chapterNumber) : chapterSummaries,
subplotBoard,
emotionalArcs,
characterMatrix,
volumeOutline,
selectedEvidenceBlock: governedMemoryBlocks
? [governedMemoryBlocks.hooksBlock, governedMemoryBlocks.summariesBlock]
.filter(Boolean)
.join("\n")
: undefined,
});
const settlement = settleResult.settlement;
const settleUsage = settleResult.usage;
@ -279,6 +288,7 @@ export class WriterAgent extends BaseAgent {
readonly emotionalArcs: string;
readonly characterMatrix: string;
readonly volumeOutline: string;
readonly selectedEvidenceBlock?: string;
}): Promise<{ settlement: ReturnType<typeof parseSettlementOutput>; usage: TokenUsage }> {
// Phase 2a: Observer — extract all facts from the chapter
const resolvedLang = params.book.language ?? params.genreProfile.language;
@ -314,6 +324,7 @@ export class WriterAgent extends BaseAgent {
characterMatrix: params.characterMatrix,
volumeOutline: params.volumeOutline,
observations,
selectedEvidenceBlock: params.selectedEvidenceBlock,
});
// Settler outputs all truth files — scale with content size

View file

@ -71,7 +71,7 @@ export { ConsolidatorAgent } from "./agents/consolidator.js";
export { MemoryDB, type Fact, type StoredSummary } from "./state/memory-db.js";
export { StateValidatorAgent } from "./agents/state-validator.js";
export { splitChapters, type SplitChapter } from "./utils/chapter-splitter.js";
export { countChapterLength, buildLengthSpec, isOutsideSoftRange, isOutsideHardRange, chooseNormalizeMode, type LengthLanguage } from "./utils/length-metrics.js";
export { countChapterLength, resolveLengthCountingMode, formatLengthCount, buildLengthSpec, isOutsideSoftRange, isOutsideHardRange, chooseNormalizeMode, type LengthLanguage } from "./utils/length-metrics.js";
export { createLogger, createStderrSink, createJsonLineSink, nullSink, type Logger, type LogSink, type LogLevel, type LogEntry } from "./utils/logger.js";
export { loadProjectConfig, GLOBAL_CONFIG_DIR, GLOBAL_ENV_PATH } from "./utils/config-loader.js";
export { computeAnalytics, type AnalyticsData, type TokenStats } from "./utils/analytics.js";

View file

@ -20,13 +20,15 @@ import { readGenreProfile } from "../agents/rules-reader.js";
import { analyzeAITells } from "../agents/ai-tells.js";
import { analyzeSensitiveWords } from "../agents/sensitive-words.js";
import { StateManager } from "../state/manager.js";
import { MemoryDB, type Fact } from "../state/memory-db.js";
import { dispatchNotification, dispatchWebhookEvent } from "../notify/dispatcher.js";
import type { WebhookEvent } from "../notify/webhook.js";
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 { buildLengthSpec, countChapterLength, isOutsideHardRange, isOutsideSoftRange } from "../utils/length-metrics.js";
import { buildLengthSpec, countChapterLength, formatLengthCount, isOutsideHardRange, isOutsideSoftRange, resolveLengthCountingMode } from "../utils/length-metrics.js";
import { parseCurrentStateFacts } from "../utils/memory-retrieval.js";
import { readFile, readdir, writeFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
@ -390,6 +392,7 @@ export class PipelineRunner {
// Snapshot
await this.state.snapshotState(bookId, chapterNumber);
await this.syncCurrentStateFactHistory(bookId, chapterNumber);
await this.emitWebhook("chapter-complete", bookId, chapterNumber, {
title: draftOutput.title,
@ -530,12 +533,17 @@ export class PipelineRunner {
const content = await this.readChapterContent(bookDir, targetChapter);
const auditor = new ContinuityAuditor(this.agentCtxFor("auditor", bookId));
const auditResult = await auditor.auditChapter(bookDir, content, targetChapter, book.genre);
const { profile: gp } = await this.loadGenreProfile(book.genre);
const countingMode = resolveLengthCountingMode(book.language ?? gp.language);
if (auditResult.passed && auditResult.issues.filter(i => i.severity === "warning" || i.severity === "critical").length === 0) {
return { chapterNumber: targetChapter, wordCount: content.length, fixedIssues: [] };
return {
chapterNumber: targetChapter,
wordCount: countChapterLength(content, countingMode),
fixedIssues: [],
};
}
const { profile: gp } = await this.loadGenreProfile(book.genre);
const lengthSpec = buildLengthSpec(
book.chapterWordCount,
book.language ?? gp.language,
@ -625,6 +633,7 @@ export class PipelineRunner {
// Re-snapshot
await this.state.snapshotState(bookId, targetChapter);
await this.syncCurrentStateFactHistory(bookId, targetChapter);
await this.emitWebhook("revision-complete", bookId, targetChapter, {
wordCount: normalizedRevision.wordCount,
@ -985,14 +994,16 @@ export class PipelineRunner {
// 5.6 Snapshot state for rollback support
await this.state.snapshotState(bookId, chapterNumber);
await this.syncCurrentStateFactHistory(bookId, chapterNumber);
// 6. Send notification
if (this.config.notifyChannels && this.config.notifyChannels.length > 0) {
const statusEmoji = auditResult.passed ? "✅" : "⚠️";
const chapterLength = formatLengthCount(finalWordCount, lengthSpec.countingMode);
await dispatchNotification(this.config.notifyChannels, {
title: `${statusEmoji} ${book.title}${chapterNumber}`,
body: [
`**${persistenceOutput.title}** | ${finalWordCount}`,
`**${persistenceOutput.title}** | ${chapterLength}`,
revised ? "📝 已自动修正" : "",
`审稿: ${auditResult.passed ? "通过" : "需人工审核"}`,
...auditResult.issues
@ -1262,6 +1273,7 @@ ${matrix}`,
log?.info(`Step 2: Sequential replay from chapter ${startFrom}...`);
const analyzer = new ChapterAnalyzerAgent(this.agentCtxFor("chapter-analyzer", input.bookId));
const writer = new WriterAgent(this.agentCtxFor("writer", input.bookId));
const countingMode = resolveLengthCountingMode(book.language ?? gp.language);
let totalWords = 0;
let importedCount = 0;
@ -1297,11 +1309,12 @@ ${matrix}`,
// Update chapter index
const existingIndex = await this.state.loadChapterIndex(input.bookId);
const now = new Date().toISOString();
const chapterWordCount = countChapterLength(ch.content, countingMode);
const newEntry: ChapterMeta = {
number: chapterNumber,
title: output.title,
status: "imported",
wordCount: ch.content.length,
wordCount: chapterWordCount,
createdAt: now,
updatedAt: now,
auditIssues: [],
@ -1318,11 +1331,15 @@ ${matrix}`,
await this.state.snapshotState(input.bookId, chapterNumber);
importedCount++;
totalWords += ch.content.length;
totalWords += chapterWordCount;
}
if (input.chapters.length > 0) {
await this.syncCurrentStateFactHistory(input.bookId, input.chapters.length);
}
const nextChapter = input.chapters.length + 1;
log?.info(`Done. ${importedCount} chapters imported, ${totalWords} chars. Next chapter: ${nextChapter}`);
log?.info(`Done. ${importedCount} chapters imported, ${formatLengthCount(totalWords, countingMode)}. Next chapter: ${nextChapter}`);
return {
bookId: input.bookId,
@ -1450,6 +1467,59 @@ ${matrix}`,
};
}
private async syncCurrentStateFactHistory(bookId: string, uptoChapter: number): Promise<void> {
let memoryDb: MemoryDB | null = null;
try {
const bookDir = this.state.bookDir(bookId);
memoryDb = new MemoryDB(bookDir);
memoryDb.resetFacts();
const activeFacts = new Map<string, { id: number; object: string }>();
for (let chapter = 0; chapter <= uptoChapter; chapter++) {
const snapshotPath = join(bookDir, "story", "snapshots", String(chapter), "current_state.md");
const markdown = await readFile(snapshotPath, "utf-8").catch(() => "");
if (!markdown) continue;
const snapshotFacts = parseCurrentStateFacts(markdown, chapter);
const nextFacts = new Map<string, Omit<Fact, "id">>();
for (const fact of snapshotFacts) {
nextFacts.set(this.factKey(fact), {
subject: fact.subject,
predicate: fact.predicate,
object: fact.object,
validFromChapter: chapter,
validUntilChapter: null,
sourceChapter: chapter,
});
}
for (const [key, previous] of activeFacts.entries()) {
const next = nextFacts.get(key);
if (!next || next.object !== previous.object) {
memoryDb.invalidateFact(previous.id, chapter);
activeFacts.delete(key);
}
}
for (const [key, fact] of nextFacts.entries()) {
if (activeFacts.has(key)) continue;
const id = memoryDb.addFact(fact);
activeFacts.set(key, { id, object: fact.object });
}
}
} catch (error) {
this.config.logger?.warn(`State fact sync skipped: ${String(error)}`);
} finally {
memoryDb?.close();
}
}
private factKey(fact: Pick<Fact, "subject" | "predicate">): string {
return `${fact.subject}::${fact.predicate}`;
}
private buildLengthWarnings(
chapterNumber: number,
finalCount: number,

View file

@ -11,6 +11,16 @@
import { join } from "node:path";
const FACT_SELECT_COLUMNS = `
id,
subject,
predicate,
object,
valid_from_chapter AS validFromChapter,
valid_until_chapter AS validUntilChapter,
source_chapter AS sourceChapter
`;
export interface Fact {
readonly id?: number;
readonly subject: string;
@ -32,6 +42,16 @@ export interface StoredSummary {
readonly chapterType: string;
}
export interface StoredHook {
readonly hookId: string;
readonly startChapter: number;
readonly type: string;
readonly status: string;
readonly lastAdvancedChapter: number;
readonly expectedPayoff: string;
readonly notes: string;
}
export class MemoryDB {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private db: any;
@ -70,9 +90,21 @@ export class MemoryDB {
chapter_type TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS hooks (
hook_id TEXT PRIMARY KEY,
start_chapter INTEGER NOT NULL DEFAULT 0,
type TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'open',
last_advanced_chapter INTEGER NOT NULL DEFAULT 0,
expected_payoff TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_facts_subject ON facts(subject);
CREATE INDEX IF NOT EXISTS idx_facts_valid ON facts(valid_from_chapter, valid_until_chapter);
CREATE INDEX IF NOT EXISTS idx_facts_source ON facts(source_chapter);
CREATE INDEX IF NOT EXISTS idx_hooks_status ON hooks(status);
CREATE INDEX IF NOT EXISTS idx_hooks_last_advanced ON hooks(last_advanced_chapter);
`);
}
@ -103,14 +135,18 @@ export class MemoryDB {
/** Get all currently valid facts (valid_until is null). */
getCurrentFacts(): ReadonlyArray<Fact> {
return this.db.prepare(
"SELECT * FROM facts WHERE valid_until_chapter IS NULL ORDER BY subject, predicate",
`SELECT ${FACT_SELECT_COLUMNS}
FROM facts
WHERE valid_until_chapter IS NULL
ORDER BY subject, predicate`,
).all() as unknown as Fact[];
}
/** Get facts about a specific subject that are valid at a given chapter. */
getFactsAt(subject: string, chapter: number): ReadonlyArray<Fact> {
return this.db.prepare(
`SELECT * FROM facts
`SELECT ${FACT_SELECT_COLUMNS}
FROM facts
WHERE subject = ? AND valid_from_chapter <= ?
AND (valid_until_chapter IS NULL OR valid_until_chapter > ?)
ORDER BY predicate`,
@ -120,14 +156,20 @@ export class MemoryDB {
/** Get all facts about a subject (including historical). */
getFactHistory(subject: string): ReadonlyArray<Fact> {
return this.db.prepare(
"SELECT * FROM facts WHERE subject = ? ORDER BY valid_from_chapter",
`SELECT ${FACT_SELECT_COLUMNS}
FROM facts
WHERE subject = ?
ORDER BY valid_from_chapter`,
).all(subject) as unknown as Fact[];
}
/** Search facts by predicate (e.g., all "location" facts). */
getFactsByPredicate(predicate: string): ReadonlyArray<Fact> {
return this.db.prepare(
"SELECT * FROM facts WHERE predicate = ? AND valid_until_chapter IS NULL ORDER BY subject",
`SELECT ${FACT_SELECT_COLUMNS}
FROM facts
WHERE predicate = ? AND valid_until_chapter IS NULL
ORDER BY subject`,
).all(predicate) as unknown as Fact[];
}
@ -136,10 +178,24 @@ export class MemoryDB {
if (names.length === 0) return [];
const placeholders = names.map(() => "?").join(",");
return this.db.prepare(
`SELECT * FROM facts WHERE subject IN (${placeholders}) AND valid_until_chapter IS NULL ORDER BY subject, predicate`,
`SELECT ${FACT_SELECT_COLUMNS}
FROM facts
WHERE subject IN (${placeholders}) AND valid_until_chapter IS NULL
ORDER BY subject, predicate`,
).all(...names) as unknown as Fact[];
}
replaceCurrentFacts(facts: ReadonlyArray<Omit<Fact, "id">>): void {
this.db.exec("DELETE FROM facts WHERE valid_until_chapter IS NULL");
for (const fact of facts) {
this.addFact(fact);
}
}
resetFacts(): void {
this.db.exec("DELETE FROM facts");
}
// ---------------------------------------------------------------------------
// Chapter summaries
// ---------------------------------------------------------------------------
@ -155,10 +211,28 @@ export class MemoryDB {
);
}
replaceSummaries(summaries: ReadonlyArray<StoredSummary>): void {
this.db.exec("DELETE FROM chapter_summaries");
for (const summary of summaries) {
this.upsertSummary(summary);
}
}
/** Get summaries for a range of chapters. */
getSummaries(fromChapter: number, toChapter: number): ReadonlyArray<StoredSummary> {
return this.db.prepare(
"SELECT * FROM chapter_summaries WHERE chapter >= ? AND chapter <= ? ORDER BY chapter",
`SELECT
chapter,
title,
characters,
events,
state_changes AS stateChanges,
hook_activity AS hookActivity,
mood,
chapter_type AS chapterType
FROM chapter_summaries
WHERE chapter >= ? AND chapter <= ?
ORDER BY chapter`,
).all(fromChapter, toChapter) as unknown as StoredSummary[];
}
@ -168,7 +242,18 @@ export class MemoryDB {
const conditions = names.map(() => "characters LIKE ?").join(" OR ");
const params = names.map((n) => `%${n}%`);
return this.db.prepare(
`SELECT * FROM chapter_summaries WHERE ${conditions} ORDER BY chapter`,
`SELECT
chapter,
title,
characters,
events,
state_changes AS stateChanges,
hook_activity AS hookActivity,
mood,
chapter_type AS chapterType
FROM chapter_summaries
WHERE ${conditions}
ORDER BY chapter`,
).all(...params) as unknown as StoredSummary[];
}
@ -181,10 +266,63 @@ export class MemoryDB {
/** Get the most recent N summaries. */
getRecentSummaries(count: number): ReadonlyArray<StoredSummary> {
return this.db.prepare(
"SELECT * FROM chapter_summaries ORDER BY chapter DESC LIMIT ?",
`SELECT
chapter,
title,
characters,
events,
state_changes AS stateChanges,
hook_activity AS hookActivity,
mood,
chapter_type AS chapterType
FROM chapter_summaries
ORDER BY chapter DESC
LIMIT ?`,
).all(count) as unknown as ReadonlyArray<StoredSummary>;
}
// ---------------------------------------------------------------------------
// Hooks
// ---------------------------------------------------------------------------
upsertHook(hook: StoredHook): void {
this.db.prepare(
`INSERT OR REPLACE INTO hooks (hook_id, start_chapter, type, status, last_advanced_chapter, expected_payoff, notes)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
).run(
hook.hookId,
hook.startChapter,
hook.type,
hook.status,
hook.lastAdvancedChapter,
hook.expectedPayoff,
hook.notes,
);
}
replaceHooks(hooks: ReadonlyArray<StoredHook>): void {
this.db.exec("DELETE FROM hooks");
for (const hook of hooks) {
this.upsertHook(hook);
}
}
getActiveHooks(): ReadonlyArray<StoredHook> {
return this.db.prepare(
`SELECT
hook_id AS hookId,
start_chapter AS startChapter,
type,
status,
last_advanced_chapter AS lastAdvancedChapter,
expected_payoff AS expectedPayoff,
notes
FROM hooks
WHERE lower(status) NOT IN ('resolved', 'closed', '已回收', '已解决')
ORDER BY last_advanced_chapter DESC, start_chapter DESC, hook_id ASC`,
).all() as unknown as ReadonlyArray<StoredHook>;
}
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------

View file

@ -108,7 +108,7 @@ function extractNames(text: string): Set<string> {
function isHeaderRow(line: string): boolean {
// First data-like row in a table (contains column names)
return /\|\s*(章节|角色|支线|hook_id|Chapter|Character|Subplot)/i.test(line);
return /^\|\s*(章节|角色|支线|hook_id|Chapter|Character|Subplot)/i.test(line);
}
/**

View file

@ -0,0 +1,33 @@
import type { ContextPackage } from "../models/input-governance.js";
export function buildGovernedMemoryEvidenceBlocks(contextPackage: ContextPackage): {
readonly hooksBlock?: string;
readonly summariesBlock?: string;
} {
const hookEntries = contextPackage.selectedContext.filter((entry) =>
entry.source.startsWith("story/pending_hooks.md#"),
);
const summaryEntries = contextPackage.selectedContext.filter((entry) =>
entry.source.startsWith("story/chapter_summaries.md#"),
);
return {
hooksBlock: hookEntries.length > 0
? renderEvidenceBlock("已选伏笔证据", hookEntries)
: undefined,
summariesBlock: summaryEntries.length > 0
? renderEvidenceBlock("已选章节摘要证据", summaryEntries)
: undefined,
};
}
function renderEvidenceBlock(
heading: string,
entries: ContextPackage["selectedContext"],
): string {
const lines = entries.map((entry) =>
`- ${entry.source}: ${entry.excerpt ?? entry.reason}`,
);
return `\n## ${heading}\n${lines.join("\n")}\n`;
}

View file

@ -20,6 +20,19 @@ export function countChapterLength(
return normalized.replace(/\s+/g, "").length;
}
export function resolveLengthCountingMode(
language: LengthLanguage = "zh",
): LengthCountingMode {
return language === "en" ? "en_words" : "zh_chars";
}
export function formatLengthCount(
count: number,
countingMode: LengthCountingMode,
): string {
return countingMode === "en_words" ? `${count} words` : `${count}`;
}
export function buildLengthSpec(
target: number,
language: LengthLanguage = "zh",
@ -37,7 +50,7 @@ export function buildLengthSpec(
softMax,
hardMin,
hardMax,
countingMode: language === "en" ? "en_words" : "zh_chars",
countingMode: resolveLengthCountingMode(language),
normalizeMode: "none",
};
}

View file

@ -0,0 +1,378 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { MemoryDB, type Fact, type StoredHook, type StoredSummary } from "../state/memory-db.js";
export interface MemorySelection {
readonly summaries: ReadonlyArray<StoredSummary>;
readonly hooks: ReadonlyArray<StoredHook>;
readonly facts: ReadonlyArray<Fact>;
readonly dbPath?: string;
}
export async function retrieveMemorySelection(params: {
readonly bookDir: string;
readonly chapterNumber: number;
readonly goal: string;
readonly outlineNode?: string;
readonly mustKeep?: ReadonlyArray<string>;
}): Promise<MemorySelection> {
const storyDir = join(params.bookDir, "story");
const [summariesMarkdown, hooksMarkdown, currentStateMarkdown] = await Promise.all([
readFile(join(storyDir, "chapter_summaries.md"), "utf-8").catch(() => ""),
readFile(join(storyDir, "pending_hooks.md"), "utf-8").catch(() => ""),
readFile(join(storyDir, "current_state.md"), "utf-8").catch(() => ""),
]);
const summaries = parseChapterSummariesMarkdown(summariesMarkdown);
const hooks = parsePendingHooksMarkdown(hooksMarkdown);
const facts = parseCurrentStateFacts(
currentStateMarkdown,
Math.max(0, params.chapterNumber - 1),
);
const queryTerms = extractQueryTerms(
params.goal,
params.outlineNode,
params.mustKeep ?? [],
);
const memoryDb = openMemoryDB(params.bookDir);
if (memoryDb) {
try {
memoryDb.replaceSummaries(summaries);
memoryDb.replaceHooks(hooks);
memoryDb.replaceCurrentFacts(facts);
return {
summaries: selectRelevantSummaries(
memoryDb.getSummaries(1, Math.max(1, params.chapterNumber - 1)),
params.chapterNumber,
queryTerms,
),
hooks: selectRelevantHooks(memoryDb.getActiveHooks(), queryTerms),
facts: selectRelevantFacts(memoryDb.getCurrentFacts(), queryTerms),
dbPath: join(storyDir, "memory.db"),
};
} finally {
memoryDb.close();
}
}
return {
summaries: selectRelevantSummaries(summaries, params.chapterNumber, queryTerms),
hooks: selectRelevantHooks(hooks, queryTerms),
facts: selectRelevantFacts(facts, queryTerms),
};
}
export function renderSummarySnapshot(summaries: ReadonlyArray<StoredSummary>): string {
if (summaries.length === 0) return "- none";
return [
"| 章节 | 标题 | 出场人物 | 关键事件 | 状态变化 | 伏笔动态 | 情绪基调 | 章节类型 |",
"| --- | --- | --- | --- | --- | --- | --- | --- |",
...summaries.map((summary) => [
summary.chapter,
summary.title,
summary.characters,
summary.events,
summary.stateChanges,
summary.hookActivity,
summary.mood,
summary.chapterType,
].map(escapeTableCell).join(" | ")).map((row) => `| ${row} |`),
].join("\n");
}
export function renderHookSnapshot(hooks: ReadonlyArray<StoredHook>): string {
if (hooks.length === 0) return "- none";
return [
"| hook_id | 起始章节 | 类型 | 状态 | 最近推进 | 预期回收 | 备注 |",
"| --- | --- | --- | --- | --- | --- | --- |",
...hooks.map((hook) => [
hook.hookId,
hook.startChapter,
hook.type,
hook.status,
hook.lastAdvancedChapter,
hook.expectedPayoff,
hook.notes,
].map((cell) => escapeTableCell(String(cell))).join(" | ")).map((row) => `| ${row} |`),
].join("\n");
}
function openMemoryDB(bookDir: string): MemoryDB | null {
try {
return new MemoryDB(bookDir);
} catch {
return null;
}
}
function extractQueryTerms(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 parseChapterSummariesMarkdown(markdown: string): StoredSummary[] {
const rows = parseMarkdownTableRows(markdown)
.filter((row) => /^\d+$/.test(row[0] ?? ""));
return rows.map((row) => ({
chapter: parseInt(row[0]!, 10),
title: row[1] ?? "",
characters: row[2] ?? "",
events: row[3] ?? "",
stateChanges: row[4] ?? "",
hookActivity: row[5] ?? "",
mood: row[6] ?? "",
chapterType: row[7] ?? "",
}));
}
function parsePendingHooksMarkdown(markdown: string): StoredHook[] {
const tableRows = parseMarkdownTableRows(markdown)
.filter((row) => (row[0] ?? "").toLowerCase() !== "hook_id");
if (tableRows.length > 0) {
return tableRows
.filter((row) => (row[0] ?? "").length > 0)
.map((row) => ({
hookId: row[0] ?? "",
startChapter: parseInteger(row[1]),
type: row[2] ?? "",
status: row[3] ?? "open",
lastAdvancedChapter: parseInteger(row[4]),
expectedPayoff: row[5] ?? "",
notes: row[6] ?? "",
}));
}
return markdown
.split("\n")
.map((line) => line.trim())
.filter((line) => line.startsWith("-"))
.map((line) => line.replace(/^-\s*/, ""))
.filter(Boolean)
.map((line, index) => ({
hookId: `hook-${index + 1}`,
startChapter: 0,
type: "unspecified",
status: "open",
lastAdvancedChapter: 0,
expectedPayoff: "",
notes: line,
}));
}
export function parseCurrentStateFacts(
markdown: string,
fallbackChapter: number,
): Fact[] {
const tableRows = parseMarkdownTableRows(markdown);
const fieldValueRows = tableRows
.filter((row) => row.length >= 2)
.filter((row) => !isStateTableHeaderRow(row));
if (fieldValueRows.length > 0) {
const chapterFromTable = fieldValueRows.find((row) => isCurrentChapterLabel(row[0] ?? ""));
const stateChapter = parseInteger(chapterFromTable?.[1]) || fallbackChapter;
return fieldValueRows
.filter((row) => !isCurrentChapterLabel(row[0] ?? ""))
.flatMap((row): Fact[] => {
const label = (row[0] ?? "").trim();
const value = (row[1] ?? "").trim();
if (!label || !value) return [];
return [{
subject: inferFactSubject(label),
predicate: label,
object: value,
validFromChapter: stateChapter,
validUntilChapter: null,
sourceChapter: stateChapter,
}];
});
}
const bulletFacts = markdown
.split("\n")
.map((line) => line.trim())
.filter((line) => line.startsWith("-"))
.map((line) => line.replace(/^-\s*/, ""))
.filter(Boolean);
return bulletFacts.map((line, index) => ({
subject: "current_state",
predicate: `note_${index + 1}`,
object: line,
validFromChapter: fallbackChapter,
validUntilChapter: null,
sourceChapter: fallbackChapter,
}));
}
function parseMarkdownTableRows(markdown: string): string[][] {
return markdown
.split("\n")
.map((line) => line.trim())
.filter((line) => line.startsWith("|"))
.filter((line) => !line.includes("---"))
.map((line) => line.split("|").slice(1, -1).map((cell) => cell.trim()))
.filter((cells) => cells.some(Boolean));
}
function isStateTableHeaderRow(row: ReadonlyArray<string>): boolean {
const first = (row[0] ?? "").trim().toLowerCase();
const second = (row[1] ?? "").trim().toLowerCase();
return (first === "字段" && second === "值") || (first === "field" && second === "value");
}
function isCurrentChapterLabel(label: string): boolean {
return /^(当前章节|current chapter)$/i.test(label.trim());
}
function inferFactSubject(label: string): string {
if (/^(当前位置|current location)$/i.test(label)) return "protagonist";
if (/^(主角状态|protagonist state)$/i.test(label)) return "protagonist";
if (/^(当前目标|current goal)$/i.test(label)) return "protagonist";
if (/^(当前限制|current constraint)$/i.test(label)) return "protagonist";
if (/^(当前敌我|current alliances|current relationships)$/i.test(label)) return "protagonist";
if (/^(当前冲突|current conflict)$/i.test(label)) return "protagonist";
return "current_state";
}
function selectRelevantSummaries(
summaries: ReadonlyArray<StoredSummary>,
chapterNumber: number,
queryTerms: ReadonlyArray<string>,
): StoredSummary[] {
return 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)
.sort((left, right) => right.score - left.score || right.summary.chapter - left.summary.chapter)
.slice(0, 4)
.map((entry) => entry.summary)
.sort((left, right) => left.chapter - right.chapter);
}
function selectRelevantHooks(
hooks: ReadonlyArray<StoredHook>,
queryTerms: ReadonlyArray<string>,
): StoredHook[] {
return hooks
.map((hook) => ({
hook,
score: scoreHook(hook, queryTerms),
matched: matchesAny(
[hook.hookId, hook.type, hook.expectedPayoff, hook.notes].join(" "),
queryTerms,
),
}))
.filter((entry) => entry.matched || entry.hook.status.trim().length === 0 || /open|待定|推进|active/i.test(entry.hook.status))
.sort((left, right) => right.score - left.score || right.hook.lastAdvancedChapter - left.hook.lastAdvancedChapter)
.slice(0, 3)
.map((entry) => entry.hook);
}
function selectRelevantFacts(
facts: ReadonlyArray<Fact>,
queryTerms: ReadonlyArray<string>,
): Fact[] {
const prioritizedPredicates = [
/^(当前冲突|current conflict)$/i,
/^(当前目标|current goal)$/i,
/^(主角状态|protagonist state)$/i,
/^(当前限制|current constraint)$/i,
/^(当前位置|current location)$/i,
/^(当前敌我|current alliances|current relationships)$/i,
];
return facts
.map((fact) => {
const text = [fact.subject, fact.predicate, fact.object].join(" ");
const priority = prioritizedPredicates.findIndex((pattern) => pattern.test(fact.predicate));
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,
);
return {
fact,
score: baseScore + termScore,
matched: matchesAny(text, queryTerms),
};
})
.filter((entry) => entry.matched || entry.score >= 14)
.sort((left, right) => right.score - left.score)
.slice(0, 4)
.map((entry) => entry.fact);
}
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>): number {
const text = [hook.hookId, hook.type, hook.expectedPayoff, 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 parseInteger(value: string | undefined): number {
if (!value) return 0;
const match = value.match(/\d+/);
return match ? parseInt(match[0], 10) : 0;
}
function escapeTableCell(value: string | number): string {
return String(value).replace(/\|/g, "\\|").trim();
}

View file

@ -7,13 +7,19 @@
* Expects process.cwd() to be the package directory (npm/pnpm guarantee this).
*/
import { readFile, writeFile, copyFile, rm } from "node:fs/promises";
import { readFile, writeFile, copyFile, rm, rename } from "node:fs/promises";
import { join, resolve } from "node:path";
const packageDir = process.cwd();
const packageJsonPath = join(packageDir, "package.json");
const backupPath = join(packageDir, ".package.json.publish-backup");
async function writeAtomic(path, content) {
const tempPath = `${path}.tmp-${process.pid}-${Date.now()}`;
await writeFile(tempPath, content, "utf-8");
await rename(tempPath, path);
}
// Walk up to workspace root (contains pnpm-workspace.yaml)
function findWorkspaceRoot(startDir) {
let dir = startDir;
@ -97,7 +103,7 @@ async function main() {
}
}
await writeFile(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf-8");
await writeAtomic(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`);
process.stderr.write(`[prepack] Replaced workspace:* deps in ${pkg.name}\n`);
// Verify: re-read and confirm no workspace: references remain
@ -120,7 +126,7 @@ async function main() {
);
// Restore backup before aborting
const original = await readFile(backupPath, "utf-8");
await writeFile(packageJsonPath, original, "utf-8");
await writeAtomic(packageJsonPath, original);
await rm(backupPath, { force: true });
process.exit(1);
}

View file

@ -5,17 +5,23 @@
* "postpack": "node ../../scripts/restore-package-json.mjs"
*/
import { readFile, rm, writeFile } from "node:fs/promises";
import { readFile, rm, writeFile, rename } from "node:fs/promises";
import { join } from "node:path";
const packageDir = process.cwd();
const packageJsonPath = join(packageDir, "package.json");
const backupPath = join(packageDir, ".package.json.publish-backup");
async function writeAtomic(path, content) {
const tempPath = `${path}.tmp-${process.pid}-${Date.now()}`;
await writeFile(tempPath, content, "utf-8");
await rename(tempPath, path);
}
async function main() {
try {
const original = await readFile(backupPath, "utf-8");
await writeFile(packageJsonPath, original, "utf-8");
await writeAtomic(packageJsonPath, original);
await rm(backupPath, { force: true });
} catch {
// No backup means prepack found nothing to replace — fine.