mirror of
https://github.com/Narcooo/inkos
synced 2026-04-21 14:37:16 +00:00
feat: improve governed memory retrieval
This commit is contained in:
parent
a5246cec02
commit
3a3db03f5d
30 changed files with 2016 additions and 78 deletions
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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]"));
|
||||
|
|
|
|||
104
packages/core/src/__tests__/chapter-analyzer.test.ts
Normal file
104
packages/core/src/__tests__/chapter-analyzer.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
20
packages/core/src/__tests__/context-filter.test.ts
Normal file
20
packages/core/src/__tests__/context-filter.test.ts
Normal 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 |");
|
||||
});
|
||||
});
|
||||
131
packages/core/src/__tests__/continuity.test.ts
Normal file
131
packages/core/src/__tests__/continuity.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
64
packages/core/src/__tests__/memory-retrieval.test.ts
Normal file
64
packages/core/src/__tests__/memory-retrieval.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 |");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
163
packages/core/src/__tests__/writer.test.ts
Normal file
163
packages/core/src/__tests__/writer.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
33
packages/core/src/utils/governed-context.ts
Normal file
33
packages/core/src/utils/governed-context.ts
Normal 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`;
|
||||
}
|
||||
|
|
@ -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",
|
||||
};
|
||||
}
|
||||
|
|
|
|||
378
packages/core/src/utils/memory-retrieval.ts
Normal file
378
packages/core/src/utils/memory-retrieval.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue