| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545 |
- import { readFile, writeFile, mkdir, readdir, rm, stat, unlink, open } from "node:fs/promises";
- import { join } from "node:path";
- import type { BookConfig } from "../models/book.js";
- import type { ChapterMeta } from "../models/chapter.js";
- import { bootstrapStructuredStateFromMarkdown, resolveDurableStoryProgress } from "./state-bootstrap.js";
- export class StateManager {
- constructor(private readonly projectRoot: string) {}
- private static defaultAuthorIntent(language: "zh" | "en"): string {
- return language === "zh"
- ? "# 作者意图\n\n(在这里描述这本书的长期创作方向。)\n"
- : "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n";
- }
- private static defaultCurrentFocus(language: "zh" | "en"): string {
- return language === "zh"
- ? "# 当前聚焦\n\n## 当前重点\n\n(描述接下来 1-3 章最需要优先推进的内容。)\n"
- : "# Current Focus\n\n## Active Focus\n\n(Describe what the next 1-3 chapters should prioritize.)\n";
- }
- async ensureControlDocuments(bookId: string, authorIntent?: string): Promise<void> {
- const language = await this.resolveControlDocumentLanguage(bookId);
- await this.ensureControlDocumentsAt(this.bookDir(bookId), language, authorIntent);
- }
- async ensureControlDocumentsAt(
- bookDir: string,
- language: "zh" | "en",
- authorIntent?: string,
- ): Promise<void> {
- const storyDir = join(bookDir, "story");
- const runtimeDir = join(storyDir, "runtime");
- await mkdir(storyDir, { recursive: true });
- await mkdir(runtimeDir, { recursive: true });
- await this.writeIfMissing(
- join(storyDir, "author_intent.md"),
- authorIntent?.trim()
- ? authorIntent.trimEnd() + "\n"
- : StateManager.defaultAuthorIntent(language),
- );
- await this.writeIfMissing(
- join(storyDir, "current_focus.md"),
- StateManager.defaultCurrentFocus(language),
- );
- // Ensure style_guide includes writing methodology even without reference text
- const styleGuidePath = join(storyDir, "style_guide.md");
- try {
- const existing = await readFile(styleGuidePath, "utf-8");
- if (!existing.includes("写作方法论") && !existing.includes("Writing Methodology")) {
- const { buildWritingMethodologySection } = await import("../utils/writing-methodology.js");
- await writeFile(styleGuidePath, `${existing}\n\n${buildWritingMethodologySection(language)}`, "utf-8");
- }
- } catch {
- const { buildWritingMethodologySection } = await import("../utils/writing-methodology.js");
- await writeFile(styleGuidePath, buildWritingMethodologySection(language), "utf-8");
- }
- }
- async loadControlDocuments(bookId: string): Promise<{
- authorIntent: string;
- currentFocus: string;
- runtimeDir: string;
- }> {
- await this.ensureControlDocuments(bookId);
- const storyDir = join(this.bookDir(bookId), "story");
- const runtimeDir = join(storyDir, "runtime");
- const [authorIntent, currentFocus] = await Promise.all([
- readFile(join(storyDir, "author_intent.md"), "utf-8"),
- readFile(join(storyDir, "current_focus.md"), "utf-8"),
- ]);
- return { authorIntent, currentFocus, runtimeDir };
- }
- private async resolveControlDocumentLanguage(bookId: string): Promise<"zh" | "en"> {
- try {
- const raw = await readFile(join(this.bookDir(bookId), "book.json"), "utf-8");
- const parsed = JSON.parse(raw) as { language?: unknown };
- return parsed.language === "zh" ? "zh" : "en";
- } catch {
- return "en";
- }
- }
- async acquireBookLock(bookId: string): Promise<() => Promise<void>> {
- await mkdir(this.bookDir(bookId), { recursive: true });
- const lockPath = join(this.bookDir(bookId), ".write.lock");
- try {
- const handle = await open(lockPath, "wx");
- try {
- await handle.writeFile(`pid:${process.pid} ts:${Date.now()}`, "utf-8");
- } catch (error) {
- await handle.close().catch(() => undefined);
- await unlink(lockPath).catch(() => undefined);
- throw error;
- }
- await handle.close();
- } catch (e) {
- const code = (e as NodeJS.ErrnoException | undefined)?.code;
- if (code === "EEXIST") {
- const lockData = await readFile(lockPath, "utf-8").catch(() => "pid:unknown ts:unknown");
- const lockPid = this.extractLockPid(lockData);
- if (lockPid !== undefined && !this.isProcessAlive(lockPid)) {
- await unlink(lockPath).catch(() => undefined);
- return this.acquireBookLock(bookId);
- }
- throw new Error(
- `Book "${bookId}" is locked by another process (${lockData}). ` +
- `If this is stale, delete ${lockPath}`,
- );
- }
- throw e;
- }
- return async () => {
- try {
- await unlink(lockPath);
- } catch {
- // ignore
- }
- };
- }
- private extractLockPid(lockData: string): number | undefined {
- const match = lockData.match(/pid:(\d+)/);
- if (!match) return undefined;
- const pid = Number.parseInt(match[1] ?? "", 10);
- return Number.isInteger(pid) && pid > 0 ? pid : undefined;
- }
- private isProcessAlive(pid: number): boolean {
- try {
- process.kill(pid, 0);
- return true;
- } catch (error) {
- const code = (error as NodeJS.ErrnoException | undefined)?.code;
- if (code === "ESRCH") {
- return false;
- }
- return true;
- }
- }
- get booksDir(): string {
- return join(this.projectRoot, "books");
- }
- bookDir(bookId: string): string {
- return join(this.booksDir, bookId);
- }
- stateDir(bookId: string): string {
- return join(this.bookDir(bookId), "story", "state");
- }
- async loadProjectConfig(): Promise<Record<string, unknown>> {
- const configPath = join(this.projectRoot, "inkos.json");
- const raw = await readFile(configPath, "utf-8");
- return JSON.parse(raw);
- }
- async saveProjectConfig(config: Record<string, unknown>): Promise<void> {
- const configPath = join(this.projectRoot, "inkos.json");
- await writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
- }
- async loadBookConfig(bookId: string): Promise<BookConfig> {
- const configPath = join(this.bookDir(bookId), "book.json");
- const raw = await readFile(configPath, "utf-8");
- if (!raw.trim()) {
- throw new Error(`book.json is empty for book "${bookId}"`);
- }
- return JSON.parse(raw) as BookConfig;
- }
- async saveBookConfig(bookId: string, config: BookConfig): Promise<void> {
- await this.saveBookConfigAt(this.bookDir(bookId), config);
- }
- async saveBookConfigAt(bookDir: string, config: BookConfig): Promise<void> {
- await mkdir(bookDir, { recursive: true });
- await writeFile(
- join(bookDir, "book.json"),
- JSON.stringify(config, null, 2),
- "utf-8",
- );
- }
- async ensureRuntimeState(bookId: string, fallbackChapter = 0): Promise<void> {
- await bootstrapStructuredStateFromMarkdown({
- bookDir: this.bookDir(bookId),
- fallbackChapter,
- });
- }
- async listBooks(): Promise<ReadonlyArray<string>> {
- try {
- const entries = await readdir(this.booksDir);
- const bookIds: string[] = [];
- for (const entry of entries) {
- const bookJsonPath = join(this.booksDir, entry, "book.json");
- try {
- await stat(bookJsonPath);
- bookIds.push(entry);
- } catch {
- // not a book directory
- }
- }
- return bookIds;
- } catch {
- return [];
- }
- }
- async getNextChapterNumber(bookId: string): Promise<number> {
- const durableChapter = await resolveDurableStoryProgress({
- bookDir: this.bookDir(bookId),
- });
- // Ensure structured state is bootstrapped (side-effect: creates missing
- // JSON files), but do NOT trust its chapter number for progress — only
- // the contiguous durable artifact chain is authoritative.
- await bootstrapStructuredStateFromMarkdown({
- bookDir: this.bookDir(bookId),
- fallbackChapter: durableChapter,
- });
- return durableChapter + 1;
- }
- async getPersistedChapterCount(bookId: string): Promise<number> {
- const chaptersDir = join(this.bookDir(bookId), "chapters");
- const chapterNumbers = new Set<number>();
- try {
- const files = await readdir(chaptersDir);
- for (const file of files) {
- const match = file.match(/^(\d+)_.*\.md$/);
- if (!match) continue;
- chapterNumbers.add(parseInt(match[1]!, 10));
- }
- } catch {
- return 0;
- }
- return chapterNumbers.size;
- }
- async loadChapterIndex(bookId: string): Promise<ReadonlyArray<ChapterMeta>> {
- const indexPath = join(this.bookDir(bookId), "chapters", "index.json");
- try {
- const raw = await readFile(indexPath, "utf-8");
- return JSON.parse(raw);
- } catch {
- return [];
- }
- }
- async saveChapterIndex(
- bookId: string,
- index: ReadonlyArray<ChapterMeta>,
- ): Promise<void> {
- await this.saveChapterIndexAt(this.bookDir(bookId), index);
- }
- async saveChapterIndexAt(
- bookDir: string,
- index: ReadonlyArray<ChapterMeta>,
- ): Promise<void> {
- const chaptersDir = join(bookDir, "chapters");
- await mkdir(chaptersDir, { recursive: true });
- await writeFile(
- join(chaptersDir, "index.json"),
- JSON.stringify(index, null, 2),
- "utf-8",
- );
- }
- async snapshotState(bookId: string, chapterNumber: number): Promise<void> {
- await this.snapshotStateAt(this.bookDir(bookId), chapterNumber);
- }
- async snapshotStateAt(bookDir: string, chapterNumber: number): Promise<void> {
- const storyDir = join(bookDir, "story");
- const snapshotDir = join(storyDir, "snapshots", String(chapterNumber));
- await mkdir(snapshotDir, { recursive: true });
- const files = [
- "current_state.md", "particle_ledger.md", "pending_hooks.md",
- "chapter_summaries.md", "subplot_board.md", "emotional_arcs.md", "character_matrix.md",
- ];
- await Promise.all(
- files.map(async (f) => {
- try {
- const content = await readFile(join(storyDir, f), "utf-8");
- await writeFile(join(snapshotDir, f), content, "utf-8");
- } catch {
- // file doesn't exist yet
- }
- }),
- );
- const stateDir = join(bookDir, "story", "state");
- const snapshotStateDir = join(snapshotDir, "state");
- try {
- const stateFiles = await readdir(stateDir);
- if (stateFiles.length > 0) {
- await mkdir(snapshotStateDir, { recursive: true });
- await Promise.all(
- stateFiles.map(async (fileName) => {
- const content = await readFile(join(stateDir, fileName), "utf-8");
- await writeFile(join(snapshotStateDir, fileName), content, "utf-8");
- }),
- );
- }
- } catch {
- // state directory missing — skip
- }
- }
- async isCompleteBookDirectory(bookDir: string): Promise<boolean> {
- // Phase 5 cleanup: prefer outline/* paths, fall back to legacy flat files
- // so older books on disk still resolve as complete.
- const requiredSingle = [
- join(bookDir, "book.json"),
- join(bookDir, "story", "book_rules.md"),
- join(bookDir, "story", "current_state.md"),
- join(bookDir, "story", "pending_hooks.md"),
- join(bookDir, "chapters", "index.json"),
- ];
- const eitherOr: Array<ReadonlyArray<string>> = [
- // story_frame (new) OR story_bible (legacy)
- [
- join(bookDir, "story", "outline", "story_frame.md"),
- join(bookDir, "story", "story_bible.md"),
- ],
- // volume_map (new) OR volume_outline (legacy)
- [
- join(bookDir, "story", "outline", "volume_map.md"),
- join(bookDir, "story", "volume_outline.md"),
- ],
- ];
- for (const requiredPath of requiredSingle) {
- try {
- await stat(requiredPath);
- } catch {
- return false;
- }
- }
- for (const alternatives of eitherOr) {
- let found = false;
- for (const candidate of alternatives) {
- try {
- await stat(candidate);
- found = true;
- break;
- } catch {
- // try next alternative
- }
- }
- if (!found) return false;
- }
- return true;
- }
- async restoreState(bookId: string, chapterNumber: number): Promise<boolean> {
- const storyDir = join(this.bookDir(bookId), "story");
- const snapshotDir = join(storyDir, "snapshots", String(chapterNumber));
- const files = [
- "current_state.md", "particle_ledger.md", "pending_hooks.md",
- "chapter_summaries.md", "subplot_board.md", "emotional_arcs.md", "character_matrix.md",
- ];
- try {
- // current_state.md and pending_hooks.md are required;
- // particle_ledger.md is optional (numericalSystem=false genres don't have it)
- // the rest are optional (may not exist in older snapshots)
- const requiredFiles = ["current_state.md", "pending_hooks.md"];
- const optionalFiles = files.filter((f) => !requiredFiles.includes(f));
- await Promise.all(
- requiredFiles.map(async (f) => {
- const content = await readFile(join(snapshotDir, f), "utf-8");
- await writeFile(join(storyDir, f), content, "utf-8");
- }),
- );
- await Promise.all(
- optionalFiles.map(async (f) => {
- try {
- const content = await readFile(join(snapshotDir, f), "utf-8");
- await writeFile(join(storyDir, f), content, "utf-8");
- } catch {
- // Optional file missing — skip
- }
- }),
- );
- const stateDir = this.stateDir(bookId);
- let restoredStructuredState = false;
- try {
- const snapshotStateDir = join(snapshotDir, "state");
- const stateFiles = await readdir(snapshotStateDir);
- if (stateFiles.length > 0) {
- restoredStructuredState = true;
- await mkdir(stateDir, { recursive: true });
- await Promise.all(
- stateFiles.map(async (fileName) => {
- const content = await readFile(join(snapshotStateDir, fileName), "utf-8");
- await writeFile(join(stateDir, fileName), content, "utf-8");
- }),
- );
- }
- } catch {
- // snapshot structured state missing — skip
- }
- if (!restoredStructuredState) {
- await rm(stateDir, { recursive: true, force: true });
- }
- return true;
- } catch {
- return false;
- }
- }
- /**
- * Roll back state to the snapshot at `targetChapter`, removing all chapters
- * after it and their associated files (chapter markdown, snapshots, runtime).
- * Used by review reject to undo a bad chapter and everything that followed.
- *
- * Returns the list of chapter numbers that were discarded.
- */
- async rollbackToChapter(
- bookId: string,
- targetChapter: number,
- ): Promise<ReadonlyArray<number>> {
- const restored = await this.restoreState(bookId, targetChapter);
- if (!restored) {
- throw new Error(`Cannot restore snapshot for chapter ${targetChapter} in "${bookId}"`);
- }
- const bookDir = this.bookDir(bookId);
- const chaptersDir = join(bookDir, "chapters");
- const index = await this.loadChapterIndex(bookId);
- const kept: ChapterMeta[] = [];
- const discarded: number[] = [];
- for (const entry of index) {
- if (entry.number <= targetChapter) {
- kept.push(entry);
- } else {
- discarded.push(entry.number);
- }
- }
- // Delete chapter markdown files for discarded chapters
- try {
- const files = await readdir(chaptersDir);
- for (const file of files) {
- const match = file.match(/^(\d+)_.*\.md$/);
- if (!match) continue;
- const num = parseInt(match[1]!, 10);
- if (num > targetChapter) {
- await unlink(join(chaptersDir, file)).catch(() => {});
- }
- }
- } catch {
- // chapters directory missing
- }
- // Delete snapshots for discarded chapters
- const snapshotsDir = join(bookDir, "story", "snapshots");
- try {
- const snapshots = await readdir(snapshotsDir);
- for (const snap of snapshots) {
- const num = parseInt(snap, 10);
- if (Number.isFinite(num) && num > targetChapter) {
- await rm(join(snapshotsDir, snap), { recursive: true, force: true });
- }
- }
- } catch {
- // snapshots directory missing
- }
- // Delete runtime artifacts for discarded chapters
- const runtimeDir = join(bookDir, "story", "runtime");
- try {
- const runtimeFiles = await readdir(runtimeDir);
- for (const file of runtimeFiles) {
- const match = file.match(/^chapter-(\d+)\./);
- if (!match) continue;
- const num = parseInt(match[1]!, 10);
- if (num > targetChapter) {
- await unlink(join(runtimeDir, file)).catch(() => {});
- }
- }
- } catch {
- // runtime directory missing
- }
- // Also check story/drafts/ for discarded chapter files
- const draftsDir = join(bookDir, "story", "drafts");
- try {
- const draftFiles = await readdir(draftsDir);
- for (const file of draftFiles) {
- const match = file.match(/^(\d+)_.*\.md$/);
- if (!match) continue;
- const num = parseInt(match[1]!, 10);
- if (num > targetChapter) {
- await unlink(join(draftsDir, file)).catch(() => {});
- }
- }
- } catch {
- // drafts directory missing
- }
- // Drop any persisted sqlite acceleration index so discarded chapters
- // cannot leak back into retrieval after the markdown/state rollback.
- await Promise.all([
- rm(join(bookDir, "story", "memory.db"), { force: true }),
- rm(join(bookDir, "story", "memory.db-shm"), { force: true }),
- rm(join(bookDir, "story", "memory.db-wal"), { force: true }),
- ]);
- await this.saveChapterIndex(bookId, kept);
- return discarded;
- }
- private async writeIfMissing(path: string, content: string): Promise<void> {
- try {
- await stat(path);
- } catch {
- await writeFile(path, content, "utf-8");
- }
- }
- }
|