write.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. import { Command } from "commander";
  2. import { PipelineRunner, StateManager } from "@actalk/inkos-core";
  3. import { readdir, unlink } from "node:fs/promises";
  4. import { join } from "node:path";
  5. import { createInterface } from "node:readline";
  6. import { loadConfig, buildPipelineConfig, findProjectRoot, getLegacyMigrationHint, resolveContext, resolveBookId, log, logError } from "../utils.js";
  7. import { formatWriteNextComplete, formatWriteNextProgress, formatWriteNextResultLines, resolveCliLanguage } from "../localization.js";
  8. export const writeCommand = new Command("write")
  9. .description("Write chapters");
  10. writeCommand
  11. .command("next")
  12. .description("Write the next chapter for a book")
  13. .argument("[book-id]", "Book ID (auto-detected if only one book)")
  14. .option("--count <n>", "Number of chapters to write", "1")
  15. .option("--words <n>", "Words per chapter (overrides book config)")
  16. .option("--context <text>", "Creative guidance (natural language)")
  17. .option("--context-file <path>", "Read guidance from file")
  18. .option("--json", "Output JSON")
  19. .option("-q, --quiet", "Suppress console output")
  20. .action(async (bookIdArg: string | undefined, opts) => {
  21. try {
  22. const root = findProjectRoot();
  23. const bookId = await resolveBookId(bookIdArg, root);
  24. const context = await resolveContext(opts);
  25. const state = new StateManager(root);
  26. const book = await state.loadBookConfig(bookId);
  27. const language = resolveCliLanguage(book.language);
  28. const migrationHint = await getLegacyMigrationHint(root, bookId);
  29. if (migrationHint && !opts.json) {
  30. log(`[migration] ${migrationHint}`);
  31. }
  32. const config = await loadConfig();
  33. const pipeline = new PipelineRunner(buildPipelineConfig(config, root, { externalContext: context, quiet: opts.quiet }));
  34. const count = parseInt(opts.count, 10);
  35. const wordCount = opts.words ? parseInt(opts.words, 10) : undefined;
  36. const results = [];
  37. for (let i = 0; i < count; i++) {
  38. if (!opts.json) log(formatWriteNextProgress(language, i + 1, count, bookId));
  39. const result = await pipeline.writeNextChapter(bookId, wordCount);
  40. results.push(result);
  41. if (!opts.json) {
  42. for (const line of formatWriteNextResultLines(language, {
  43. chapterNumber: result.chapterNumber,
  44. title: result.title,
  45. wordCount: result.wordCount,
  46. auditPassed: result.auditResult.passed,
  47. revised: result.revised,
  48. status: result.status,
  49. issues: result.auditResult.issues,
  50. })) {
  51. log(line);
  52. }
  53. log("");
  54. }
  55. if (result.status === "state-degraded") {
  56. if (!opts.json) {
  57. log(language === "en"
  58. ? "State repair required before continuing. Stopping batch."
  59. : "需要先修复 state,已停止后续连写。");
  60. }
  61. break;
  62. }
  63. }
  64. if (opts.json) {
  65. log(JSON.stringify(results, null, 2));
  66. } else {
  67. log(formatWriteNextComplete(language));
  68. }
  69. } catch (e) {
  70. if (opts.json) {
  71. log(JSON.stringify({ error: String(e) }));
  72. } else {
  73. logError(`Failed to write chapter: ${e}`);
  74. }
  75. process.exit(1);
  76. }
  77. });
  78. writeCommand
  79. .command("rewrite")
  80. .description("Re-generate a specific chapter: rewrite [book-id] <chapter>")
  81. .argument("<args...>", "Book ID (optional) and chapter number")
  82. .option("--force", "Skip confirmation prompt")
  83. .option("--words <n>", "Words per chapter (overrides book config)")
  84. .option("--json", "Output JSON")
  85. .action(async (args: ReadonlyArray<string>, opts) => {
  86. try {
  87. const root = findProjectRoot();
  88. let bookId: string;
  89. let chapter: number;
  90. if (args.length === 1) {
  91. chapter = parseInt(args[0]!, 10);
  92. if (isNaN(chapter)) throw new Error(`Expected chapter number, got "${args[0]}"`);
  93. bookId = await resolveBookId(undefined, root);
  94. } else if (args.length === 2) {
  95. chapter = parseInt(args[1]!, 10);
  96. if (isNaN(chapter)) throw new Error(`Expected chapter number, got "${args[1]}"`);
  97. bookId = await resolveBookId(args[0], root);
  98. } else {
  99. throw new Error("Usage: inkos write rewrite [book-id] <chapter>");
  100. }
  101. if (!opts.force) {
  102. const rl = createInterface({ input: process.stdin, output: process.stdout });
  103. const answer = await new Promise<string>((resolve) => {
  104. rl.question(`Rewrite chapter ${chapter} of "${bookId}"? This will delete chapter ${chapter} and all later chapters. (y/N) `, resolve);
  105. });
  106. rl.close();
  107. if (answer.toLowerCase() !== "y") {
  108. log("Cancelled.");
  109. return;
  110. }
  111. }
  112. const state = new StateManager(root);
  113. const bookDir = state.bookDir(bookId);
  114. const chaptersDir = join(bookDir, "chapters");
  115. const migrationHint = await getLegacyMigrationHint(root, bookId);
  116. if (migrationHint && !opts.json) {
  117. log(`[migration] ${migrationHint}`);
  118. }
  119. // Remove existing chapter file
  120. const files = await readdir(chaptersDir);
  121. const paddedNum = String(chapter).padStart(4, "0");
  122. const existing = files.filter((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
  123. for (const f of existing) {
  124. await unlink(join(chaptersDir, f));
  125. if (!opts.json) log(`Removed: ${f}`);
  126. }
  127. // Remove from index (and all chapters after it)
  128. const index = await state.loadChapterIndex(bookId);
  129. const trimmed = index.filter((ch) => ch.number < chapter);
  130. await state.saveChapterIndex(bookId, trimmed);
  131. // Also remove later chapter files since state will be rolled back
  132. const laterFiles = files.filter((f) => {
  133. const num = parseInt(f.slice(0, 4), 10);
  134. return num > chapter && f.endsWith(".md");
  135. });
  136. for (const f of laterFiles) {
  137. await unlink(join(chaptersDir, f));
  138. if (!opts.json) log(`Removed later chapter: ${f}`);
  139. }
  140. // Restore state to previous chapter's end-state (chapter 1 uses snapshot-0 from initBook)
  141. const restoreFrom = chapter - 1;
  142. const restored = await state.restoreState(bookId, restoreFrom);
  143. if (restored) {
  144. if (!opts.json) log(`State restored from chapter ${restoreFrom} snapshot.`);
  145. } else {
  146. if (!opts.json) log(`Warning: no snapshot for chapter ${restoreFrom}. Using current state.`);
  147. }
  148. if (!opts.json) log(`Regenerating chapter ${chapter}...`);
  149. const wordCount = opts.words ? parseInt(opts.words, 10) : undefined;
  150. const config = await loadConfig();
  151. const pipeline = new PipelineRunner(buildPipelineConfig(config, root));
  152. const result = await pipeline.writeNextChapter(bookId, wordCount);
  153. const book = await state.loadBookConfig(bookId);
  154. const language = resolveCliLanguage(book.language);
  155. if (opts.json) {
  156. log(JSON.stringify(result, null, 2));
  157. } else {
  158. for (const line of formatWriteNextResultLines(language, {
  159. chapterNumber: result.chapterNumber,
  160. title: result.title,
  161. wordCount: result.wordCount,
  162. auditPassed: result.auditResult.passed,
  163. revised: result.revised,
  164. status: result.status,
  165. issues: result.auditResult.issues,
  166. })) {
  167. log(line);
  168. }
  169. }
  170. } catch (e) {
  171. if (opts.json) {
  172. log(JSON.stringify({ error: String(e) }));
  173. } else {
  174. logError(`Failed to rewrite chapter: ${e}`);
  175. }
  176. process.exit(1);
  177. }
  178. });
  179. writeCommand
  180. .command("repair-state")
  181. .description("Rebuild truth files for a persisted state-degraded chapter without rewriting body text")
  182. .argument("<args...>", "Book ID (optional) and chapter number")
  183. .option("--json", "Output JSON")
  184. .action(async (args: ReadonlyArray<string>, opts) => {
  185. try {
  186. const root = findProjectRoot();
  187. let bookId: string;
  188. let chapter: number;
  189. if (args.length === 1) {
  190. chapter = parseInt(args[0]!, 10);
  191. if (isNaN(chapter)) throw new Error(`Expected chapter number, got "${args[0]}"`);
  192. bookId = await resolveBookId(undefined, root);
  193. } else if (args.length === 2) {
  194. chapter = parseInt(args[1]!, 10);
  195. if (isNaN(chapter)) throw new Error(`Expected chapter number, got "${args[1]}"`);
  196. bookId = await resolveBookId(args[0], root);
  197. } else {
  198. throw new Error("Usage: inkos write repair-state [book-id] <chapter>");
  199. }
  200. const state = new StateManager(root);
  201. const book = await state.loadBookConfig(bookId);
  202. const language = resolveCliLanguage(book.language);
  203. const config = await loadConfig();
  204. const pipeline = new PipelineRunner(buildPipelineConfig(config, root));
  205. const result = await pipeline.repairChapterState(bookId, chapter);
  206. if (opts.json) {
  207. log(JSON.stringify(result, null, 2));
  208. } else {
  209. for (const line of formatWriteNextResultLines(language, {
  210. chapterNumber: result.chapterNumber,
  211. title: result.title,
  212. wordCount: result.wordCount,
  213. auditPassed: result.auditResult.passed,
  214. revised: result.revised,
  215. status: result.status,
  216. issues: result.auditResult.issues,
  217. })) {
  218. log(line);
  219. }
  220. }
  221. } catch (e) {
  222. if (opts.json) {
  223. log(JSON.stringify({ error: String(e) }));
  224. } else {
  225. logError(`Failed to repair chapter state: ${e}`);
  226. }
  227. process.exit(1);
  228. }
  229. });