| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403 |
- 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),
- );
- }
- 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 index = await this.loadChapterIndex(bookId);
- const indexedChapter = index.length > 0
- ? Math.max(...index.map((ch) => ch.number))
- : 0;
- const runtimeState = await bootstrapStructuredStateFromMarkdown({
- bookDir: this.bookDir(bookId),
- fallbackChapter: indexedChapter,
- });
- const durableChapter = await resolveDurableStoryProgress({
- bookDir: this.bookDir(bookId),
- fallbackChapter: indexedChapter,
- });
- return Math.max(indexedChapter, durableChapter, runtimeState.manifest.lastAppliedChapter) + 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> {
- const requiredPaths = [
- join(bookDir, "book.json"),
- join(bookDir, "story", "story_bible.md"),
- join(bookDir, "story", "volume_outline.md"),
- join(bookDir, "story", "book_rules.md"),
- join(bookDir, "story", "current_state.md"),
- join(bookDir, "story", "pending_hooks.md"),
- join(bookDir, "chapters", "index.json"),
- ];
- for (const requiredPath of requiredPaths) {
- try {
- await stat(requiredPath);
- } catch {
- 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;
- }
- }
- private async writeIfMissing(path: string, content: string): Promise<void> {
- try {
- await stat(path);
- } catch {
- await writeFile(path, content, "utf-8");
- }
- }
- }
|