edit-controller.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. import { readdir, readFile, unlink, writeFile } from "node:fs/promises";
  2. import { join, relative } from "node:path";
  3. import type { ChapterMeta } from "../models/chapter.js";
  4. import { classifyTruthAuthority, normalizeTruthFileName, type TruthAuthority } from "./truth-authority.js";
  5. export type EditRequest =
  6. | {
  7. readonly kind: "entity-rename";
  8. readonly bookId: string;
  9. readonly entityType: "protagonist" | "character" | "location" | "organization";
  10. readonly oldValue: string;
  11. readonly newValue: string;
  12. }
  13. | {
  14. readonly kind: "chapter-rewrite";
  15. readonly bookId: string;
  16. readonly chapterNumber: number;
  17. readonly instruction: string;
  18. }
  19. | {
  20. readonly kind: "chapter-local-edit";
  21. readonly bookId: string;
  22. readonly chapterNumber: number;
  23. readonly instruction: string;
  24. readonly targetText?: string;
  25. readonly replacementText?: string;
  26. }
  27. | {
  28. readonly kind: "truth-file-edit";
  29. readonly bookId: string;
  30. readonly fileName: string;
  31. readonly instruction: string;
  32. }
  33. | {
  34. readonly kind: "focus-edit";
  35. readonly bookId: string;
  36. readonly instruction: string;
  37. };
  38. export interface PlannedEditTransaction {
  39. readonly transactionType: EditRequest["kind"];
  40. readonly bookId: string;
  41. readonly chapterNumber?: number;
  42. readonly truthAuthority?: TruthAuthority;
  43. readonly normalizedFileName?: string;
  44. readonly affectedScope: "chapter" | "downstream" | "future" | "book";
  45. readonly requiresTruthRebuild: boolean;
  46. }
  47. export interface EditExecutionDeps {
  48. readonly bookDir: (bookId: string) => string;
  49. readonly loadChapterIndex: (bookId: string) => Promise<ReadonlyArray<ChapterMeta>>;
  50. readonly saveChapterIndex: (bookId: string, index: ReadonlyArray<ChapterMeta>) => Promise<void>;
  51. }
  52. export interface ExecutedEditTransaction {
  53. readonly transactionType: EditRequest["kind"];
  54. readonly bookId: string;
  55. readonly chapterNumber?: number;
  56. readonly touchedFiles: ReadonlyArray<string>;
  57. readonly reviewRequired: boolean;
  58. readonly summary: string;
  59. }
  60. function isMissingDirectoryError(error: unknown): boolean {
  61. return typeof error === "object"
  62. && error !== null
  63. && "code" in error
  64. && (error as { code?: unknown }).code === "ENOENT";
  65. }
  66. export function planEditTransaction(request: EditRequest): PlannedEditTransaction {
  67. switch (request.kind) {
  68. case "entity-rename":
  69. return {
  70. transactionType: request.kind,
  71. bookId: request.bookId,
  72. affectedScope: "book",
  73. requiresTruthRebuild: true,
  74. };
  75. case "chapter-rewrite":
  76. return {
  77. transactionType: request.kind,
  78. bookId: request.bookId,
  79. chapterNumber: request.chapterNumber,
  80. affectedScope: "downstream",
  81. requiresTruthRebuild: true,
  82. };
  83. case "chapter-local-edit":
  84. return {
  85. transactionType: request.kind,
  86. bookId: request.bookId,
  87. chapterNumber: request.chapterNumber,
  88. affectedScope: "chapter",
  89. requiresTruthRebuild: true,
  90. };
  91. case "truth-file-edit": {
  92. const normalizedFileName = normalizeTruthFileName(request.fileName);
  93. const truthAuthority = classifyTruthAuthority(normalizedFileName);
  94. return {
  95. transactionType: request.kind,
  96. bookId: request.bookId,
  97. normalizedFileName,
  98. truthAuthority,
  99. affectedScope: truthAuthority === "runtime-truth" ? "book" : truthAuthority === "memory" ? "book" : "book",
  100. requiresTruthRebuild: truthAuthority === "runtime-truth" || truthAuthority === "memory",
  101. };
  102. }
  103. case "focus-edit":
  104. return {
  105. transactionType: request.kind,
  106. bookId: request.bookId,
  107. truthAuthority: "direction",
  108. normalizedFileName: "current_focus.md",
  109. affectedScope: "future",
  110. requiresTruthRebuild: false,
  111. };
  112. }
  113. }
  114. function escapeRegExp(text: string): string {
  115. return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
  116. }
  117. async function collectEditableFiles(dir: string): Promise<ReadonlyArray<string>> {
  118. const entries = await readdir(dir, { withFileTypes: true }).catch((error) => {
  119. if (isMissingDirectoryError(error)) {
  120. return [];
  121. }
  122. throw error;
  123. });
  124. const files = await Promise.all(entries.map(async (entry) => {
  125. const fullPath = join(dir, entry.name);
  126. if (entry.isDirectory()) {
  127. return collectEditableFiles(fullPath);
  128. }
  129. if (!/\.(md|json|ya?ml|txt)$/i.test(entry.name)) {
  130. return [];
  131. }
  132. return [fullPath];
  133. }));
  134. return files.flat();
  135. }
  136. async function executeEntityRename(
  137. deps: EditExecutionDeps,
  138. request: Extract<EditRequest, { kind: "entity-rename" }>,
  139. ): Promise<ExecutedEditTransaction> {
  140. const root = deps.bookDir(request.bookId);
  141. const files = await collectEditableFiles(root);
  142. const touchedFiles: string[] = [];
  143. const matcher = new RegExp(escapeRegExp(request.oldValue), "g");
  144. for (const filePath of files) {
  145. const content = await readFile(filePath, "utf-8");
  146. const nextContent = content.replace(matcher, request.newValue);
  147. if (nextContent === content) {
  148. continue;
  149. }
  150. await writeFile(filePath, nextContent, "utf-8");
  151. touchedFiles.push(relative(root, filePath));
  152. }
  153. if (touchedFiles.length === 0) {
  154. throw new Error(`No occurrences of "${request.oldValue}" were found in "${request.bookId}".`);
  155. }
  156. return {
  157. transactionType: request.kind,
  158. bookId: request.bookId,
  159. touchedFiles,
  160. reviewRequired: false,
  161. summary: `Renamed ${request.oldValue} to ${request.newValue} across ${touchedFiles.length} files.`,
  162. };
  163. }
  164. async function executeChapterLocalEdit(
  165. deps: EditExecutionDeps,
  166. request: Extract<EditRequest, { kind: "chapter-local-edit" }>,
  167. ): Promise<ExecutedEditTransaction> {
  168. const root = deps.bookDir(request.bookId);
  169. const chaptersDir = join(root, "chapters");
  170. const paddedChapter = String(request.chapterNumber).padStart(4, "0");
  171. const chapterFile = (await readdir(chaptersDir).catch((error) => {
  172. if (isMissingDirectoryError(error)) {
  173. return [];
  174. }
  175. throw error;
  176. }))
  177. .find((file) => file.startsWith(`${paddedChapter}_`) && file.endsWith(".md"));
  178. if (!chapterFile) {
  179. throw new Error(`Chapter ${request.chapterNumber} not found in "${request.bookId}".`);
  180. }
  181. if (!request.targetText || request.replacementText === undefined) {
  182. throw new Error("Chapter-local edits require targetText and replacementText.");
  183. }
  184. const chapterPath = join(chaptersDir, chapterFile);
  185. const content = await readFile(chapterPath, "utf-8");
  186. const nextContent = content.split(request.targetText).join(request.replacementText);
  187. if (nextContent === content) {
  188. throw new Error(`Target text was not found in chapter ${request.chapterNumber}.`);
  189. }
  190. await writeFile(chapterPath, nextContent, "utf-8");
  191. const runtimeDir = join(root, "story", "runtime");
  192. const runtimeFiles = (await readdir(runtimeDir).catch((error) => {
  193. if (isMissingDirectoryError(error)) {
  194. return [];
  195. }
  196. throw error;
  197. }))
  198. .filter((file) => file.startsWith(`chapter-${paddedChapter}.`));
  199. await Promise.all(runtimeFiles.map((file) => unlink(join(runtimeDir, file)).catch(() => undefined)));
  200. const index = [...(await deps.loadChapterIndex(request.bookId))];
  201. const updatedIndex = index.map((chapter) => chapter.number === request.chapterNumber
  202. ? {
  203. ...chapter,
  204. status: "audit-failed" as const,
  205. updatedAt: new Date().toISOString(),
  206. auditIssues: [
  207. ...chapter.auditIssues.filter((issue) => !issue.includes("Manual text edit requires review")),
  208. "[warning] Manual text edit requires review before continuation.",
  209. ],
  210. }
  211. : chapter);
  212. await deps.saveChapterIndex(request.bookId, updatedIndex);
  213. return {
  214. transactionType: request.kind,
  215. bookId: request.bookId,
  216. chapterNumber: request.chapterNumber,
  217. touchedFiles: [
  218. relative(root, chapterPath),
  219. ...runtimeFiles.map((file) => relative(root, join(runtimeDir, file))),
  220. "chapters/index.json",
  221. ],
  222. reviewRequired: true,
  223. summary: `Patched chapter ${request.chapterNumber} and marked it for review.`,
  224. };
  225. }
  226. export async function executeEditTransaction(
  227. deps: EditExecutionDeps,
  228. request: EditRequest,
  229. ): Promise<ExecutedEditTransaction> {
  230. switch (request.kind) {
  231. case "entity-rename":
  232. return executeEntityRename(deps, request);
  233. case "chapter-local-edit":
  234. return executeChapterLocalEdit(deps, request);
  235. case "truth-file-edit": {
  236. const root = deps.bookDir(request.bookId);
  237. const normalizedFileName = normalizeTruthFileName(request.fileName);
  238. const filePath = join(root, "story", normalizedFileName);
  239. await writeFile(filePath, request.instruction, "utf-8");
  240. return {
  241. transactionType: request.kind,
  242. bookId: request.bookId,
  243. touchedFiles: [relative(root, filePath)],
  244. reviewRequired: false,
  245. summary: `Updated ${normalizedFileName}.`,
  246. };
  247. }
  248. default:
  249. throw new Error(`Edit transaction "${request.kind}" is not executable yet.`);
  250. }
  251. }