manager.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545
  1. import { readFile, writeFile, mkdir, readdir, rm, stat, unlink, open } from "node:fs/promises";
  2. import { join } from "node:path";
  3. import type { BookConfig } from "../models/book.js";
  4. import type { ChapterMeta } from "../models/chapter.js";
  5. import { bootstrapStructuredStateFromMarkdown, resolveDurableStoryProgress } from "./state-bootstrap.js";
  6. export class StateManager {
  7. constructor(private readonly projectRoot: string) {}
  8. private static defaultAuthorIntent(language: "zh" | "en"): string {
  9. return language === "zh"
  10. ? "# 作者意图\n\n(在这里描述这本书的长期创作方向。)\n"
  11. : "# Author Intent\n\n(Describe the long-horizon vision for this book here.)\n";
  12. }
  13. private static defaultCurrentFocus(language: "zh" | "en"): string {
  14. return language === "zh"
  15. ? "# 当前聚焦\n\n## 当前重点\n\n(描述接下来 1-3 章最需要优先推进的内容。)\n"
  16. : "# Current Focus\n\n## Active Focus\n\n(Describe what the next 1-3 chapters should prioritize.)\n";
  17. }
  18. async ensureControlDocuments(bookId: string, authorIntent?: string): Promise<void> {
  19. const language = await this.resolveControlDocumentLanguage(bookId);
  20. await this.ensureControlDocumentsAt(this.bookDir(bookId), language, authorIntent);
  21. }
  22. async ensureControlDocumentsAt(
  23. bookDir: string,
  24. language: "zh" | "en",
  25. authorIntent?: string,
  26. ): Promise<void> {
  27. const storyDir = join(bookDir, "story");
  28. const runtimeDir = join(storyDir, "runtime");
  29. await mkdir(storyDir, { recursive: true });
  30. await mkdir(runtimeDir, { recursive: true });
  31. await this.writeIfMissing(
  32. join(storyDir, "author_intent.md"),
  33. authorIntent?.trim()
  34. ? authorIntent.trimEnd() + "\n"
  35. : StateManager.defaultAuthorIntent(language),
  36. );
  37. await this.writeIfMissing(
  38. join(storyDir, "current_focus.md"),
  39. StateManager.defaultCurrentFocus(language),
  40. );
  41. // Ensure style_guide includes writing methodology even without reference text
  42. const styleGuidePath = join(storyDir, "style_guide.md");
  43. try {
  44. const existing = await readFile(styleGuidePath, "utf-8");
  45. if (!existing.includes("写作方法论") && !existing.includes("Writing Methodology")) {
  46. const { buildWritingMethodologySection } = await import("../utils/writing-methodology.js");
  47. await writeFile(styleGuidePath, `${existing}\n\n${buildWritingMethodologySection(language)}`, "utf-8");
  48. }
  49. } catch {
  50. const { buildWritingMethodologySection } = await import("../utils/writing-methodology.js");
  51. await writeFile(styleGuidePath, buildWritingMethodologySection(language), "utf-8");
  52. }
  53. }
  54. async loadControlDocuments(bookId: string): Promise<{
  55. authorIntent: string;
  56. currentFocus: string;
  57. runtimeDir: string;
  58. }> {
  59. await this.ensureControlDocuments(bookId);
  60. const storyDir = join(this.bookDir(bookId), "story");
  61. const runtimeDir = join(storyDir, "runtime");
  62. const [authorIntent, currentFocus] = await Promise.all([
  63. readFile(join(storyDir, "author_intent.md"), "utf-8"),
  64. readFile(join(storyDir, "current_focus.md"), "utf-8"),
  65. ]);
  66. return { authorIntent, currentFocus, runtimeDir };
  67. }
  68. private async resolveControlDocumentLanguage(bookId: string): Promise<"zh" | "en"> {
  69. try {
  70. const raw = await readFile(join(this.bookDir(bookId), "book.json"), "utf-8");
  71. const parsed = JSON.parse(raw) as { language?: unknown };
  72. return parsed.language === "zh" ? "zh" : "en";
  73. } catch {
  74. return "en";
  75. }
  76. }
  77. async acquireBookLock(bookId: string): Promise<() => Promise<void>> {
  78. await mkdir(this.bookDir(bookId), { recursive: true });
  79. const lockPath = join(this.bookDir(bookId), ".write.lock");
  80. try {
  81. const handle = await open(lockPath, "wx");
  82. try {
  83. await handle.writeFile(`pid:${process.pid} ts:${Date.now()}`, "utf-8");
  84. } catch (error) {
  85. await handle.close().catch(() => undefined);
  86. await unlink(lockPath).catch(() => undefined);
  87. throw error;
  88. }
  89. await handle.close();
  90. } catch (e) {
  91. const code = (e as NodeJS.ErrnoException | undefined)?.code;
  92. if (code === "EEXIST") {
  93. const lockData = await readFile(lockPath, "utf-8").catch(() => "pid:unknown ts:unknown");
  94. const lockPid = this.extractLockPid(lockData);
  95. if (lockPid !== undefined && !this.isProcessAlive(lockPid)) {
  96. await unlink(lockPath).catch(() => undefined);
  97. return this.acquireBookLock(bookId);
  98. }
  99. throw new Error(
  100. `Book "${bookId}" is locked by another process (${lockData}). ` +
  101. `If this is stale, delete ${lockPath}`,
  102. );
  103. }
  104. throw e;
  105. }
  106. return async () => {
  107. try {
  108. await unlink(lockPath);
  109. } catch {
  110. // ignore
  111. }
  112. };
  113. }
  114. private extractLockPid(lockData: string): number | undefined {
  115. const match = lockData.match(/pid:(\d+)/);
  116. if (!match) return undefined;
  117. const pid = Number.parseInt(match[1] ?? "", 10);
  118. return Number.isInteger(pid) && pid > 0 ? pid : undefined;
  119. }
  120. private isProcessAlive(pid: number): boolean {
  121. try {
  122. process.kill(pid, 0);
  123. return true;
  124. } catch (error) {
  125. const code = (error as NodeJS.ErrnoException | undefined)?.code;
  126. if (code === "ESRCH") {
  127. return false;
  128. }
  129. return true;
  130. }
  131. }
  132. get booksDir(): string {
  133. return join(this.projectRoot, "books");
  134. }
  135. bookDir(bookId: string): string {
  136. return join(this.booksDir, bookId);
  137. }
  138. stateDir(bookId: string): string {
  139. return join(this.bookDir(bookId), "story", "state");
  140. }
  141. async loadProjectConfig(): Promise<Record<string, unknown>> {
  142. const configPath = join(this.projectRoot, "inkos.json");
  143. const raw = await readFile(configPath, "utf-8");
  144. return JSON.parse(raw);
  145. }
  146. async saveProjectConfig(config: Record<string, unknown>): Promise<void> {
  147. const configPath = join(this.projectRoot, "inkos.json");
  148. await writeFile(configPath, JSON.stringify(config, null, 2), "utf-8");
  149. }
  150. async loadBookConfig(bookId: string): Promise<BookConfig> {
  151. const configPath = join(this.bookDir(bookId), "book.json");
  152. const raw = await readFile(configPath, "utf-8");
  153. if (!raw.trim()) {
  154. throw new Error(`book.json is empty for book "${bookId}"`);
  155. }
  156. return JSON.parse(raw) as BookConfig;
  157. }
  158. async saveBookConfig(bookId: string, config: BookConfig): Promise<void> {
  159. await this.saveBookConfigAt(this.bookDir(bookId), config);
  160. }
  161. async saveBookConfigAt(bookDir: string, config: BookConfig): Promise<void> {
  162. await mkdir(bookDir, { recursive: true });
  163. await writeFile(
  164. join(bookDir, "book.json"),
  165. JSON.stringify(config, null, 2),
  166. "utf-8",
  167. );
  168. }
  169. async ensureRuntimeState(bookId: string, fallbackChapter = 0): Promise<void> {
  170. await bootstrapStructuredStateFromMarkdown({
  171. bookDir: this.bookDir(bookId),
  172. fallbackChapter,
  173. });
  174. }
  175. async listBooks(): Promise<ReadonlyArray<string>> {
  176. try {
  177. const entries = await readdir(this.booksDir);
  178. const bookIds: string[] = [];
  179. for (const entry of entries) {
  180. const bookJsonPath = join(this.booksDir, entry, "book.json");
  181. try {
  182. await stat(bookJsonPath);
  183. bookIds.push(entry);
  184. } catch {
  185. // not a book directory
  186. }
  187. }
  188. return bookIds;
  189. } catch {
  190. return [];
  191. }
  192. }
  193. async getNextChapterNumber(bookId: string): Promise<number> {
  194. const durableChapter = await resolveDurableStoryProgress({
  195. bookDir: this.bookDir(bookId),
  196. });
  197. // Ensure structured state is bootstrapped (side-effect: creates missing
  198. // JSON files), but do NOT trust its chapter number for progress — only
  199. // the contiguous durable artifact chain is authoritative.
  200. await bootstrapStructuredStateFromMarkdown({
  201. bookDir: this.bookDir(bookId),
  202. fallbackChapter: durableChapter,
  203. });
  204. return durableChapter + 1;
  205. }
  206. async getPersistedChapterCount(bookId: string): Promise<number> {
  207. const chaptersDir = join(this.bookDir(bookId), "chapters");
  208. const chapterNumbers = new Set<number>();
  209. try {
  210. const files = await readdir(chaptersDir);
  211. for (const file of files) {
  212. const match = file.match(/^(\d+)_.*\.md$/);
  213. if (!match) continue;
  214. chapterNumbers.add(parseInt(match[1]!, 10));
  215. }
  216. } catch {
  217. return 0;
  218. }
  219. return chapterNumbers.size;
  220. }
  221. async loadChapterIndex(bookId: string): Promise<ReadonlyArray<ChapterMeta>> {
  222. const indexPath = join(this.bookDir(bookId), "chapters", "index.json");
  223. try {
  224. const raw = await readFile(indexPath, "utf-8");
  225. return JSON.parse(raw);
  226. } catch {
  227. return [];
  228. }
  229. }
  230. async saveChapterIndex(
  231. bookId: string,
  232. index: ReadonlyArray<ChapterMeta>,
  233. ): Promise<void> {
  234. await this.saveChapterIndexAt(this.bookDir(bookId), index);
  235. }
  236. async saveChapterIndexAt(
  237. bookDir: string,
  238. index: ReadonlyArray<ChapterMeta>,
  239. ): Promise<void> {
  240. const chaptersDir = join(bookDir, "chapters");
  241. await mkdir(chaptersDir, { recursive: true });
  242. await writeFile(
  243. join(chaptersDir, "index.json"),
  244. JSON.stringify(index, null, 2),
  245. "utf-8",
  246. );
  247. }
  248. async snapshotState(bookId: string, chapterNumber: number): Promise<void> {
  249. await this.snapshotStateAt(this.bookDir(bookId), chapterNumber);
  250. }
  251. async snapshotStateAt(bookDir: string, chapterNumber: number): Promise<void> {
  252. const storyDir = join(bookDir, "story");
  253. const snapshotDir = join(storyDir, "snapshots", String(chapterNumber));
  254. await mkdir(snapshotDir, { recursive: true });
  255. const files = [
  256. "current_state.md", "particle_ledger.md", "pending_hooks.md",
  257. "chapter_summaries.md", "subplot_board.md", "emotional_arcs.md", "character_matrix.md",
  258. ];
  259. await Promise.all(
  260. files.map(async (f) => {
  261. try {
  262. const content = await readFile(join(storyDir, f), "utf-8");
  263. await writeFile(join(snapshotDir, f), content, "utf-8");
  264. } catch {
  265. // file doesn't exist yet
  266. }
  267. }),
  268. );
  269. const stateDir = join(bookDir, "story", "state");
  270. const snapshotStateDir = join(snapshotDir, "state");
  271. try {
  272. const stateFiles = await readdir(stateDir);
  273. if (stateFiles.length > 0) {
  274. await mkdir(snapshotStateDir, { recursive: true });
  275. await Promise.all(
  276. stateFiles.map(async (fileName) => {
  277. const content = await readFile(join(stateDir, fileName), "utf-8");
  278. await writeFile(join(snapshotStateDir, fileName), content, "utf-8");
  279. }),
  280. );
  281. }
  282. } catch {
  283. // state directory missing — skip
  284. }
  285. }
  286. async isCompleteBookDirectory(bookDir: string): Promise<boolean> {
  287. // Phase 5 cleanup: prefer outline/* paths, fall back to legacy flat files
  288. // so older books on disk still resolve as complete.
  289. const requiredSingle = [
  290. join(bookDir, "book.json"),
  291. join(bookDir, "story", "book_rules.md"),
  292. join(bookDir, "story", "current_state.md"),
  293. join(bookDir, "story", "pending_hooks.md"),
  294. join(bookDir, "chapters", "index.json"),
  295. ];
  296. const eitherOr: Array<ReadonlyArray<string>> = [
  297. // story_frame (new) OR story_bible (legacy)
  298. [
  299. join(bookDir, "story", "outline", "story_frame.md"),
  300. join(bookDir, "story", "story_bible.md"),
  301. ],
  302. // volume_map (new) OR volume_outline (legacy)
  303. [
  304. join(bookDir, "story", "outline", "volume_map.md"),
  305. join(bookDir, "story", "volume_outline.md"),
  306. ],
  307. ];
  308. for (const requiredPath of requiredSingle) {
  309. try {
  310. await stat(requiredPath);
  311. } catch {
  312. return false;
  313. }
  314. }
  315. for (const alternatives of eitherOr) {
  316. let found = false;
  317. for (const candidate of alternatives) {
  318. try {
  319. await stat(candidate);
  320. found = true;
  321. break;
  322. } catch {
  323. // try next alternative
  324. }
  325. }
  326. if (!found) return false;
  327. }
  328. return true;
  329. }
  330. async restoreState(bookId: string, chapterNumber: number): Promise<boolean> {
  331. const storyDir = join(this.bookDir(bookId), "story");
  332. const snapshotDir = join(storyDir, "snapshots", String(chapterNumber));
  333. const files = [
  334. "current_state.md", "particle_ledger.md", "pending_hooks.md",
  335. "chapter_summaries.md", "subplot_board.md", "emotional_arcs.md", "character_matrix.md",
  336. ];
  337. try {
  338. // current_state.md and pending_hooks.md are required;
  339. // particle_ledger.md is optional (numericalSystem=false genres don't have it)
  340. // the rest are optional (may not exist in older snapshots)
  341. const requiredFiles = ["current_state.md", "pending_hooks.md"];
  342. const optionalFiles = files.filter((f) => !requiredFiles.includes(f));
  343. await Promise.all(
  344. requiredFiles.map(async (f) => {
  345. const content = await readFile(join(snapshotDir, f), "utf-8");
  346. await writeFile(join(storyDir, f), content, "utf-8");
  347. }),
  348. );
  349. await Promise.all(
  350. optionalFiles.map(async (f) => {
  351. try {
  352. const content = await readFile(join(snapshotDir, f), "utf-8");
  353. await writeFile(join(storyDir, f), content, "utf-8");
  354. } catch {
  355. // Optional file missing — skip
  356. }
  357. }),
  358. );
  359. const stateDir = this.stateDir(bookId);
  360. let restoredStructuredState = false;
  361. try {
  362. const snapshotStateDir = join(snapshotDir, "state");
  363. const stateFiles = await readdir(snapshotStateDir);
  364. if (stateFiles.length > 0) {
  365. restoredStructuredState = true;
  366. await mkdir(stateDir, { recursive: true });
  367. await Promise.all(
  368. stateFiles.map(async (fileName) => {
  369. const content = await readFile(join(snapshotStateDir, fileName), "utf-8");
  370. await writeFile(join(stateDir, fileName), content, "utf-8");
  371. }),
  372. );
  373. }
  374. } catch {
  375. // snapshot structured state missing — skip
  376. }
  377. if (!restoredStructuredState) {
  378. await rm(stateDir, { recursive: true, force: true });
  379. }
  380. return true;
  381. } catch {
  382. return false;
  383. }
  384. }
  385. /**
  386. * Roll back state to the snapshot at `targetChapter`, removing all chapters
  387. * after it and their associated files (chapter markdown, snapshots, runtime).
  388. * Used by review reject to undo a bad chapter and everything that followed.
  389. *
  390. * Returns the list of chapter numbers that were discarded.
  391. */
  392. async rollbackToChapter(
  393. bookId: string,
  394. targetChapter: number,
  395. ): Promise<ReadonlyArray<number>> {
  396. const restored = await this.restoreState(bookId, targetChapter);
  397. if (!restored) {
  398. throw new Error(`Cannot restore snapshot for chapter ${targetChapter} in "${bookId}"`);
  399. }
  400. const bookDir = this.bookDir(bookId);
  401. const chaptersDir = join(bookDir, "chapters");
  402. const index = await this.loadChapterIndex(bookId);
  403. const kept: ChapterMeta[] = [];
  404. const discarded: number[] = [];
  405. for (const entry of index) {
  406. if (entry.number <= targetChapter) {
  407. kept.push(entry);
  408. } else {
  409. discarded.push(entry.number);
  410. }
  411. }
  412. // Delete chapter markdown files for discarded chapters
  413. try {
  414. const files = await readdir(chaptersDir);
  415. for (const file of files) {
  416. const match = file.match(/^(\d+)_.*\.md$/);
  417. if (!match) continue;
  418. const num = parseInt(match[1]!, 10);
  419. if (num > targetChapter) {
  420. await unlink(join(chaptersDir, file)).catch(() => {});
  421. }
  422. }
  423. } catch {
  424. // chapters directory missing
  425. }
  426. // Delete snapshots for discarded chapters
  427. const snapshotsDir = join(bookDir, "story", "snapshots");
  428. try {
  429. const snapshots = await readdir(snapshotsDir);
  430. for (const snap of snapshots) {
  431. const num = parseInt(snap, 10);
  432. if (Number.isFinite(num) && num > targetChapter) {
  433. await rm(join(snapshotsDir, snap), { recursive: true, force: true });
  434. }
  435. }
  436. } catch {
  437. // snapshots directory missing
  438. }
  439. // Delete runtime artifacts for discarded chapters
  440. const runtimeDir = join(bookDir, "story", "runtime");
  441. try {
  442. const runtimeFiles = await readdir(runtimeDir);
  443. for (const file of runtimeFiles) {
  444. const match = file.match(/^chapter-(\d+)\./);
  445. if (!match) continue;
  446. const num = parseInt(match[1]!, 10);
  447. if (num > targetChapter) {
  448. await unlink(join(runtimeDir, file)).catch(() => {});
  449. }
  450. }
  451. } catch {
  452. // runtime directory missing
  453. }
  454. // Also check story/drafts/ for discarded chapter files
  455. const draftsDir = join(bookDir, "story", "drafts");
  456. try {
  457. const draftFiles = await readdir(draftsDir);
  458. for (const file of draftFiles) {
  459. const match = file.match(/^(\d+)_.*\.md$/);
  460. if (!match) continue;
  461. const num = parseInt(match[1]!, 10);
  462. if (num > targetChapter) {
  463. await unlink(join(draftsDir, file)).catch(() => {});
  464. }
  465. }
  466. } catch {
  467. // drafts directory missing
  468. }
  469. // Drop any persisted sqlite acceleration index so discarded chapters
  470. // cannot leak back into retrieval after the markdown/state rollback.
  471. await Promise.all([
  472. rm(join(bookDir, "story", "memory.db"), { force: true }),
  473. rm(join(bookDir, "story", "memory.db-shm"), { force: true }),
  474. rm(join(bookDir, "story", "memory.db-wal"), { force: true }),
  475. ]);
  476. await this.saveChapterIndex(bookId, kept);
  477. return discarded;
  478. }
  479. private async writeIfMissing(path: string, content: string): Promise<void> {
  480. try {
  481. await stat(path);
  482. } catch {
  483. await writeFile(path, content, "utf-8");
  484. }
  485. }
  486. }