write.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. import { Command } from "commander";
  2. import { PipelineRunner, StateManager } from "@actalk/inkos-core";
  3. import { readdir, stat, 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("--brief <text>", "One-off creative guidance for this rewrite only")
  85. .option("--json", "Output JSON")
  86. .action(async (args: ReadonlyArray<string>, opts) => {
  87. try {
  88. const root = findProjectRoot();
  89. let bookId: string;
  90. let chapter: number;
  91. if (args.length === 1) {
  92. chapter = parseInt(args[0]!, 10);
  93. if (isNaN(chapter)) throw new Error(`Expected chapter number, got "${args[0]}"`);
  94. bookId = await resolveBookId(undefined, root);
  95. } else if (args.length === 2) {
  96. chapter = parseInt(args[1]!, 10);
  97. if (isNaN(chapter)) throw new Error(`Expected chapter number, got "${args[1]}"`);
  98. bookId = await resolveBookId(args[0], root);
  99. } else {
  100. throw new Error("Usage: inkos write rewrite [book-id] <chapter>");
  101. }
  102. if (!opts.force) {
  103. const rl = createInterface({ input: process.stdin, output: process.stdout });
  104. const answer = await new Promise<string>((resolve) => {
  105. rl.question(`Rewrite chapter ${chapter} of "${bookId}"? This will delete chapter ${chapter} and all later chapters. (y/N) `, resolve);
  106. });
  107. rl.close();
  108. if (answer.toLowerCase() !== "y") {
  109. log("Cancelled.");
  110. return;
  111. }
  112. }
  113. const state = new StateManager(root);
  114. const bookDir = state.bookDir(bookId);
  115. const chaptersDir = join(bookDir, "chapters");
  116. const restoreFrom = chapter - 1;
  117. const restoreSnapshotDir = join(bookDir, "story", "snapshots", String(restoreFrom));
  118. await stat(restoreSnapshotDir).catch(() => {
  119. throw new Error(`Cannot rewrite chapter ${chapter}: missing snapshot for chapter ${restoreFrom}`);
  120. });
  121. const migrationHint = await getLegacyMigrationHint(root, bookId);
  122. if (migrationHint && !opts.json) {
  123. log(`[migration] ${migrationHint}`);
  124. }
  125. // Remove existing chapter file
  126. const files = await readdir(chaptersDir);
  127. const paddedNum = String(chapter).padStart(4, "0");
  128. const existing = files.filter((f) => f.startsWith(paddedNum) && f.endsWith(".md"));
  129. for (const f of existing) {
  130. await unlink(join(chaptersDir, f));
  131. if (!opts.json) log(`Removed: ${f}`);
  132. }
  133. // Remove from index (and all chapters after it)
  134. const index = await state.loadChapterIndex(bookId);
  135. const trimmed = index.filter((ch) => ch.number < chapter);
  136. await state.saveChapterIndex(bookId, trimmed);
  137. // Also remove later chapter files since state will be rolled back
  138. const laterFiles = files.filter((f) => {
  139. const num = parseInt(f.slice(0, 4), 10);
  140. return num > chapter && f.endsWith(".md");
  141. });
  142. for (const f of laterFiles) {
  143. await unlink(join(chaptersDir, f));
  144. if (!opts.json) log(`Removed later chapter: ${f}`);
  145. }
  146. // Restore state to previous chapter's end-state (chapter 1 uses snapshot-0 from initBook)
  147. const restored = await state.restoreState(bookId, restoreFrom);
  148. if (!restored) {
  149. throw new Error(`Cannot rewrite chapter ${chapter}: failed to restore snapshot for chapter ${restoreFrom}`);
  150. }
  151. if (!opts.json) log(`State restored from chapter ${restoreFrom} snapshot.`);
  152. const nextChapter = await state.getNextChapterNumber(bookId);
  153. if (nextChapter !== chapter) {
  154. throw new Error(`Cannot rewrite chapter ${chapter}: expected next chapter to be ${chapter}, but resolved to ${nextChapter}`);
  155. }
  156. if (!opts.json) log(`Regenerating chapter ${chapter}...`);
  157. const wordCount = opts.words ? parseInt(opts.words, 10) : undefined;
  158. const config = await loadConfig();
  159. const pipeline = new PipelineRunner(buildPipelineConfig(config, root, {
  160. externalContext: opts.brief,
  161. }));
  162. const result = await pipeline.writeNextChapter(bookId, wordCount);
  163. const book = await state.loadBookConfig(bookId);
  164. const language = resolveCliLanguage(book.language);
  165. if (opts.json) {
  166. log(JSON.stringify(result, null, 2));
  167. } else {
  168. for (const line of formatWriteNextResultLines(language, {
  169. chapterNumber: result.chapterNumber,
  170. title: result.title,
  171. wordCount: result.wordCount,
  172. auditPassed: result.auditResult.passed,
  173. revised: result.revised,
  174. status: result.status,
  175. issues: result.auditResult.issues,
  176. })) {
  177. log(line);
  178. }
  179. }
  180. } catch (e) {
  181. if (opts.json) {
  182. log(JSON.stringify({ error: String(e) }));
  183. } else {
  184. logError(`Failed to rewrite chapter: ${e}`);
  185. }
  186. process.exit(1);
  187. }
  188. });
  189. writeCommand
  190. .command("sync")
  191. .description("Rebuild truth files and SQLite indexes from the latest edited chapter body")
  192. .argument("<args...>", "Book ID (optional) and chapter number")
  193. .option("--brief <text>", "One-off guidance for how to interpret the edited chapter while syncing")
  194. .option("--json", "Output JSON")
  195. .action(async (args: ReadonlyArray<string>, opts) => {
  196. try {
  197. const root = findProjectRoot();
  198. let bookId: string;
  199. let chapter: number;
  200. if (args.length === 1) {
  201. chapter = parseInt(args[0]!, 10);
  202. if (isNaN(chapter)) throw new Error(`Expected chapter number, got "${args[0]}"`);
  203. bookId = await resolveBookId(undefined, root);
  204. } else if (args.length === 2) {
  205. chapter = parseInt(args[1]!, 10);
  206. if (isNaN(chapter)) throw new Error(`Expected chapter number, got "${args[1]}"`);
  207. bookId = await resolveBookId(args[0], root);
  208. } else {
  209. throw new Error("Usage: inkos write sync [book-id] <chapter>");
  210. }
  211. const state = new StateManager(root);
  212. const book = await state.loadBookConfig(bookId);
  213. const language = resolveCliLanguage(book.language);
  214. const config = await loadConfig();
  215. const pipeline = new PipelineRunner(buildPipelineConfig(config, root, {
  216. externalContext: opts.brief,
  217. }));
  218. const result = await pipeline.resyncChapterArtifacts(bookId, chapter);
  219. if (opts.json) {
  220. log(JSON.stringify(result, null, 2));
  221. } else {
  222. for (const line of formatWriteNextResultLines(language, {
  223. chapterNumber: result.chapterNumber,
  224. title: result.title,
  225. wordCount: result.wordCount,
  226. auditPassed: result.auditResult.passed,
  227. revised: result.revised,
  228. status: result.status,
  229. issues: result.auditResult.issues,
  230. })) {
  231. log(line);
  232. }
  233. }
  234. } catch (e) {
  235. if (opts.json) {
  236. log(JSON.stringify({ error: String(e) }));
  237. } else {
  238. logError(`Failed to sync chapter artifacts: ${e}`);
  239. }
  240. process.exit(1);
  241. }
  242. });
  243. writeCommand
  244. .command("repair-state")
  245. .description("Rebuild truth files for a persisted state-degraded chapter without rewriting body text")
  246. .argument("<args...>", "Book ID (optional) and chapter number")
  247. .option("--json", "Output JSON")
  248. .action(async (args: ReadonlyArray<string>, opts) => {
  249. try {
  250. const root = findProjectRoot();
  251. let bookId: string;
  252. let chapter: number;
  253. if (args.length === 1) {
  254. chapter = parseInt(args[0]!, 10);
  255. if (isNaN(chapter)) throw new Error(`Expected chapter number, got "${args[0]}"`);
  256. bookId = await resolveBookId(undefined, root);
  257. } else if (args.length === 2) {
  258. chapter = parseInt(args[1]!, 10);
  259. if (isNaN(chapter)) throw new Error(`Expected chapter number, got "${args[1]}"`);
  260. bookId = await resolveBookId(args[0], root);
  261. } else {
  262. throw new Error("Usage: inkos write repair-state [book-id] <chapter>");
  263. }
  264. const state = new StateManager(root);
  265. const book = await state.loadBookConfig(bookId);
  266. const language = resolveCliLanguage(book.language);
  267. const config = await loadConfig();
  268. const pipeline = new PipelineRunner(buildPipelineConfig(config, root));
  269. const result = await pipeline.repairChapterState(bookId, chapter);
  270. if (opts.json) {
  271. log(JSON.stringify(result, null, 2));
  272. } else {
  273. for (const line of formatWriteNextResultLines(language, {
  274. chapterNumber: result.chapterNumber,
  275. title: result.title,
  276. wordCount: result.wordCount,
  277. auditPassed: result.auditResult.passed,
  278. revised: result.revised,
  279. status: result.status,
  280. issues: result.auditResult.issues,
  281. })) {
  282. log(line);
  283. }
  284. }
  285. } catch (e) {
  286. if (opts.json) {
  287. log(JSON.stringify({ error: String(e) }));
  288. } else {
  289. logError(`Failed to repair chapter state: ${e}`);
  290. }
  291. process.exit(1);
  292. }
  293. });