Просмотр исходного кода

feat: add state snapshots and chapter rewrite command

- StateManager: snapshot/restore state per chapter for rollback
- Pipeline: auto-snapshot state after each chapter write
- CLI: `inkos write rewrite <book-id> <chapter>` removes chapter
  and all later chapters, restores state, regenerates fresh
majx_mac 8 месяцев назад
Родитель
Сommit
8c5637b391

+ 74 - 1
packages/cli/src/commands/write.ts

@@ -1,5 +1,7 @@
 import { Command } from "commander";
-import { PipelineRunner } from "@inkos/core";
+import { PipelineRunner, StateManager } from "@inkos/core";
+import { readdir, unlink } from "node:fs/promises";
+import { join } from "node:path";
 import { loadConfig, createClient, findProjectRoot, log, logError } from "../utils.js";
 
 export const writeCommand = new Command("write")
@@ -54,3 +56,74 @@ writeCommand
       process.exit(1);
     }
   });
+
+writeCommand
+  .command("rewrite")
+  .description("Re-generate a specific chapter (removes it and writes fresh)")
+  .argument("<book-id>", "Book ID")
+  .argument("<chapter>", "Chapter number to rewrite")
+  .action(async (bookId: string, chapterStr: string) => {
+    try {
+      const config = await loadConfig();
+      const client = createClient(config);
+      const root = findProjectRoot();
+      const chapterNum = parseInt(chapterStr, 10);
+
+      const state = new StateManager(root);
+      const bookDir = state.bookDir(bookId);
+      const chaptersDir = join(bookDir, "chapters");
+
+      // Remove existing chapter file
+      const files = await readdir(chaptersDir);
+      const paddedNum = String(chapterNum).padStart(4, "0");
+      const existing = files.filter((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
+      for (const f of existing) {
+        await unlink(join(chaptersDir, f));
+        log(`Removed: ${f}`);
+      }
+
+      // Remove from index (and all chapters after it)
+      const index = await state.loadChapterIndex(bookId);
+      const trimmed = index.filter((ch) => ch.number < chapterNum);
+      await state.saveChapterIndex(bookId, trimmed);
+
+      // Also remove later chapter files since state will be rolled back
+      const laterFiles = files.filter((f) => {
+        const num = parseInt(f.slice(0, 4), 10);
+        return num > chapterNum && f.endsWith(".md");
+      });
+      for (const f of laterFiles) {
+        await unlink(join(chaptersDir, f));
+        log(`Removed later chapter: ${f}`);
+      }
+
+      // Restore state to previous chapter's end-state
+      if (chapterNum > 1) {
+        const restored = await state.restoreState(bookId, chapterNum - 1);
+        if (restored) {
+          log(`State restored from chapter ${chapterNum - 1} snapshot.`);
+        } else {
+          log(`Warning: no snapshot for chapter ${chapterNum - 1}. Using current state.`);
+        }
+      }
+
+      log(`Regenerating chapter ${chapterNum}...`);
+
+      const pipeline = new PipelineRunner({
+        client,
+        model: config.llm.model,
+        projectRoot: root,
+        notifyChannels: config.notify,
+      });
+
+      const result = await pipeline.writeNextChapter(bookId);
+
+      log(`  Chapter ${result.chapterNumber}: ${result.title}`);
+      log(`  Words: ${result.wordCount}`);
+      log(`  Audit: ${result.auditResult.passed ? "PASSED" : "NEEDS REVIEW"}`);
+      log(`  Status: ${result.status}`);
+    } catch (e) {
+      logError(`Failed to rewrite chapter: ${e}`);
+      process.exit(1);
+    }
+  });

+ 3 - 0
packages/core/src/pipeline/runner.ts

@@ -173,6 +173,9 @@ export class PipelineRunner {
     };
     await this.state.saveChapterIndex(bookId, [...existingIndex, newEntry]);
 
+    // 5.5 Snapshot state for rollback support
+    await this.state.snapshotState(bookId, chapterNumber);
+
     // 6. Send notification
     if (this.config.notifyChannels && this.config.notifyChannels.length > 0) {
       const statusEmoji = auditResult.passed ? "✅" : "⚠️";

+ 36 - 0
packages/core/src/state/manager.ts

@@ -111,4 +111,40 @@ export class StateManager {
       "utf-8",
     );
   }
+
+  async snapshotState(bookId: string, chapterNumber: number): Promise<void> {
+    const storyDir = join(this.bookDir(bookId), "story");
+    const snapshotDir = join(storyDir, "snapshots", String(chapterNumber));
+    await mkdir(snapshotDir, { recursive: true });
+
+    const files = ["current_state.md", "particle_ledger.md", "pending_hooks.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
+        }
+      }),
+    );
+  }
+
+  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"];
+    try {
+      await Promise.all(
+        files.map(async (f) => {
+          const content = await readFile(join(snapshotDir, f), "utf-8");
+          await writeFile(join(storyDir, f), content, "utf-8");
+        }),
+      );
+      return true;
+    } catch {
+      return false;
+    }
+  }
 }