project-tools.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671
  1. import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
  2. import { dirname, join } from "node:path";
  3. import type {
  4. InteractionEvent,
  5. Logger,
  6. PipelineRunner,
  7. StateManager,
  8. ReviseMode,
  9. LLMClient,
  10. BookConfig,
  11. Platform,
  12. ToolDefinition,
  13. } from "../index.js";
  14. import { chatCompletion, chatWithTools } from "../index.js";
  15. import { executeEditTransaction } from "./edit-controller.js";
  16. import type { InteractionRuntimeTools } from "./runtime.js";
  17. import type { BookCreationDraft } from "./session.js";
  18. import { writeExportArtifact } from "./export-artifact.js";
  19. type PipelineLike = Pick<PipelineRunner, "writeNextChapter" | "reviseDraft"> & {
  20. readonly initBook?: (
  21. book: BookConfig,
  22. options?: {
  23. readonly externalContext?: string;
  24. readonly authorIntent?: string;
  25. readonly currentFocus?: string;
  26. },
  27. ) => Promise<void>;
  28. };
  29. type StateLike = Pick<StateManager, "ensureControlDocuments" | "bookDir" | "loadBookConfig" | "loadChapterIndex" | "saveChapterIndex" | "listBooks">;
  30. type InstrumentablePipelineLike = PipelineLike & {
  31. readonly config?: {
  32. logger?: Logger;
  33. client?: LLMClient;
  34. model?: string;
  35. };
  36. };
  37. function normalizePlatform(platform?: string): Platform {
  38. switch (platform) {
  39. case "tomato":
  40. case "feilu":
  41. case "qidian":
  42. return platform;
  43. default:
  44. return "other";
  45. }
  46. }
  47. function deriveBookId(title: string): string {
  48. return title
  49. .toLowerCase()
  50. .replace(/[^a-z0-9\u4e00-\u9fff]/g, "-")
  51. .replace(/-+/g, "-")
  52. .slice(0, 30);
  53. }
  54. function buildBookConfig(input: {
  55. readonly title: string;
  56. readonly genre?: string;
  57. readonly platform?: string;
  58. readonly language?: "zh" | "en";
  59. readonly chapterWordCount?: number;
  60. readonly targetChapters?: number;
  61. }): BookConfig {
  62. const now = new Date().toISOString();
  63. return {
  64. id: deriveBookId(input.title),
  65. title: input.title,
  66. platform: normalizePlatform(input.platform),
  67. genre: input.genre ?? "other",
  68. status: "outlining",
  69. targetChapters: input.targetChapters ?? 200,
  70. chapterWordCount: input.chapterWordCount ?? 3000,
  71. ...(input.language ? { language: input.language } : {}),
  72. createdAt: now,
  73. updatedAt: now,
  74. };
  75. }
  76. function buildCreationExternalContext(input: {
  77. readonly blurb?: string;
  78. readonly worldPremise?: string;
  79. readonly settingNotes?: string;
  80. readonly protagonist?: string;
  81. readonly supportingCast?: string;
  82. readonly conflictCore?: string;
  83. readonly volumeOutline?: string;
  84. readonly constraints?: string;
  85. }): string | undefined {
  86. const sections = [
  87. input.worldPremise ? `## 世界观与核心设定\n${input.worldPremise}` : undefined,
  88. input.settingNotes ? `## 补充设定\n${input.settingNotes}` : undefined,
  89. input.protagonist ? `## 主角设定\n${input.protagonist}` : undefined,
  90. input.supportingCast ? `## 关键角色与势力\n${input.supportingCast}` : undefined,
  91. input.conflictCore ? `## 核心冲突\n${input.conflictCore}` : undefined,
  92. input.volumeOutline ? `## 卷纲方向\n${input.volumeOutline}` : undefined,
  93. input.blurb ? `## 简介卖点\n${input.blurb}` : undefined,
  94. input.constraints ? `## 创作约束\n${input.constraints}` : undefined,
  95. ].filter((section): section is string => Boolean(section?.trim()));
  96. if (sections.length === 0) {
  97. return undefined;
  98. }
  99. return sections.join("\n\n");
  100. }
  101. export function buildChapterFileLookup(files: ReadonlyArray<string>): ReadonlyMap<number, string> {
  102. const lookup = new Map<number, string>();
  103. for (const file of files) {
  104. if (!file.endsWith(".md") || !/^\d{4}/.test(file)) {
  105. continue;
  106. }
  107. const chapterNumber = parseInt(file.slice(0, 4), 10);
  108. if (!lookup.has(chapterNumber)) {
  109. lookup.set(chapterNumber, file);
  110. }
  111. }
  112. return lookup;
  113. }
  114. async function exportBookToPath(state: StateLike, bookId: string, options: {
  115. readonly format?: "txt" | "md" | "epub";
  116. readonly approvedOnly?: boolean;
  117. readonly outputPath?: string;
  118. }) {
  119. return writeExportArtifact(state, bookId, options);
  120. }
  121. function mapStageMessageToStatus(message: string): InteractionEvent["status"] | undefined {
  122. const lower = message.trim().toLowerCase();
  123. if (
  124. lower.includes("planning next chapter")
  125. || lower.includes("generating foundation")
  126. || lower.includes("reviewing foundation")
  127. || lower.includes("preparing chapter inputs")
  128. || message.includes("规划下一章意图")
  129. || message.includes("生成基础设定")
  130. || message.includes("审核基础设定")
  131. || message.includes("准备章节输入")
  132. ) {
  133. return "planning";
  134. }
  135. if (
  136. lower.includes("composing chapter runtime context")
  137. || message.includes("组装章节运行时上下文")
  138. ) {
  139. return "composing";
  140. }
  141. if (
  142. lower.includes("writing chapter draft")
  143. || message.includes("撰写章节草稿")
  144. ) {
  145. return "writing";
  146. }
  147. if (
  148. lower.includes("auditing draft")
  149. || message.includes("审计草稿")
  150. ) {
  151. return "assessing";
  152. }
  153. if (
  154. lower.includes("fixing")
  155. || lower.includes("revising chapter")
  156. || lower.includes("rewrite")
  157. || lower.includes("repair")
  158. || message.includes("自动修复")
  159. || message.includes("整章改写")
  160. || message.includes("修订第")
  161. ) {
  162. return "repairing";
  163. }
  164. if (
  165. lower.includes("persist")
  166. || lower.includes("saving")
  167. || lower.includes("snapshot")
  168. || lower.includes("rebuilding final truth files")
  169. || lower.includes("validating truth file updates")
  170. || lower.includes("syncing memory indexes")
  171. || message.includes("落盘")
  172. || message.includes("保存")
  173. || message.includes("快照")
  174. || message.includes("校验真相文件变更")
  175. || message.includes("生成最终真相文件")
  176. || message.includes("同步记忆索引")
  177. ) {
  178. return "persisting";
  179. }
  180. return undefined;
  181. }
  182. function extractStageDetail(message: string): string | undefined {
  183. if (message.startsWith("Stage: ")) {
  184. return message.slice("Stage: ".length).trim();
  185. }
  186. if (message.startsWith("阶段:")) {
  187. return message.slice("阶段:".length).trim();
  188. }
  189. return undefined;
  190. }
  191. function createInteractionLogger(
  192. original: Logger | undefined,
  193. events: InteractionEvent[],
  194. bookId: string,
  195. ): Logger {
  196. const emit = (level: "debug" | "info" | "warn" | "error", message: string): void => {
  197. const stageDetail = extractStageDetail(message);
  198. const stageStatus = stageDetail ? mapStageMessageToStatus(stageDetail) : undefined;
  199. if (stageDetail && stageStatus) {
  200. events.push({
  201. kind: "stage.changed",
  202. timestamp: Date.now(),
  203. status: stageStatus,
  204. bookId,
  205. detail: stageDetail,
  206. });
  207. return;
  208. }
  209. if (level === "warn") {
  210. events.push({
  211. kind: "task.warning",
  212. timestamp: Date.now(),
  213. status: "blocked",
  214. bookId,
  215. detail: message,
  216. });
  217. return;
  218. }
  219. if (level === "error") {
  220. events.push({
  221. kind: "task.failed",
  222. timestamp: Date.now(),
  223. status: "failed",
  224. bookId,
  225. detail: message,
  226. });
  227. }
  228. };
  229. const wrap = (base: Logger | undefined): Logger => ({
  230. debug: (msg, ctx) => {
  231. emit("debug", msg);
  232. base?.debug(msg, ctx);
  233. },
  234. info: (msg, ctx) => {
  235. emit("info", msg);
  236. base?.info(msg, ctx);
  237. },
  238. warn: (msg, ctx) => {
  239. emit("warn", msg);
  240. base?.warn(msg, ctx);
  241. },
  242. error: (msg, ctx) => {
  243. emit("error", msg);
  244. base?.error(msg, ctx);
  245. },
  246. child: (tag, extraCtx) => wrap(base?.child(tag, extraCtx)),
  247. });
  248. return wrap(original);
  249. }
  250. async function withPipelineInteractionTelemetry<T extends { chapterNumber?: number }>(
  251. pipeline: InstrumentablePipelineLike,
  252. bookId: string,
  253. executor: () => Promise<T>,
  254. ): Promise<T & {
  255. __interaction: {
  256. events: ReadonlyArray<InteractionEvent>;
  257. activeChapterNumber?: number;
  258. };
  259. }> {
  260. const events: InteractionEvent[] = [];
  261. const originalLogger = pipeline.config?.logger;
  262. if (pipeline.config) {
  263. pipeline.config.logger = createInteractionLogger(originalLogger, events, bookId);
  264. }
  265. try {
  266. const result = await executor();
  267. return {
  268. ...result,
  269. __interaction: {
  270. events,
  271. ...(typeof result.chapterNumber === "number"
  272. ? { activeChapterNumber: result.chapterNumber }
  273. : {}),
  274. },
  275. };
  276. } finally {
  277. if (pipeline.config) {
  278. pipeline.config.logger = originalLogger;
  279. }
  280. }
  281. }
  282. const CREATE_BOOK_TOOL: ToolDefinition = {
  283. name: "create_book",
  284. description: "根据用户描述生成建书参数。系统会将参数渲染为可编辑表单,用户确认后建书。",
  285. parameters: {
  286. type: "object",
  287. properties: {
  288. title: { type: "string", description: "书名" },
  289. genre: { type: "string", description: "题材标识,如 xuanhuan, urban, romance, scifi, mystery" },
  290. platform: { type: "string", enum: ["tomato", "qidian", "feilu", "other"], description: "发布平台" },
  291. targetChapters: { type: "number", description: "目标章数,默认 200" },
  292. chapterWordCount: { type: "number", description: "每章字数,默认 3000" },
  293. language: { type: "string", enum: ["zh", "en"], description: "写作语言,默认 zh" },
  294. brief: { type: "string", description: "创意简述,会传给 Architect 智能体生成完整的世界观、主角、冲突等 foundation 文件。把用户提到的所有创意要素都写进这里。" },
  295. },
  296. required: ["title", "genre", "platform", "brief"],
  297. },
  298. };
  299. const BOOK_DRAFT_SYSTEM_PROMPT = [
  300. "你是 InkOS 的建书助手。用户会描述想写的书,你需要调用 create_book 工具来生成建书参数。",
  301. "",
  302. "规则:",
  303. "1. 从用户描述中推断所有字段,大胆预填合理默认值。",
  304. "2. brief 字段要详细——它会传给 Architect 智能体生成完整的世界观、主角、冲突等 foundation 文件。把用户提到的所有创意要素都写进 brief。",
  305. "3. 如果用户后续要求修改某些字段,重新调用 create_book 工具,只更新被提到的字段,其余保持不变。",
  306. "4. 不要只回复文字讨论——必须调用 create_book 工具输出结构化参数。",
  307. ].join("\n");
  308. /** Map directive field keys to BookCreationDraft property names. */
  309. function applyFieldsToDraft(
  310. existing: BookCreationDraft | undefined,
  311. fields: Readonly<Record<string, string>>,
  312. concept: string,
  313. ): BookCreationDraft {
  314. const draft: BookCreationDraft = {
  315. concept,
  316. missingFields: [],
  317. readyToCreate: false,
  318. ...(existing ?? {}),
  319. };
  320. for (const [key, value] of Object.entries(fields)) {
  321. if (!value) continue;
  322. switch (key) {
  323. case "title":
  324. draft.title = value;
  325. break;
  326. case "genre":
  327. draft.genre = value;
  328. break;
  329. case "platform":
  330. draft.platform = value;
  331. break;
  332. case "language":
  333. if (value === "zh" || value === "en") draft.language = value;
  334. break;
  335. case "targetChapters": {
  336. const n = parseInt(value, 10);
  337. if (!Number.isNaN(n) && n > 0) draft.targetChapters = n;
  338. break;
  339. }
  340. case "chapterWordCount":
  341. case "chapterLength": {
  342. const n = parseInt(value, 10);
  343. if (!Number.isNaN(n) && n > 0) draft.chapterWordCount = n;
  344. break;
  345. }
  346. case "blurb":
  347. draft.blurb = value;
  348. break;
  349. case "worldPremise":
  350. draft.worldPremise = value;
  351. break;
  352. case "settingNotes":
  353. draft.settingNotes = value;
  354. break;
  355. case "protagonist":
  356. draft.protagonist = value;
  357. break;
  358. case "supportingCast":
  359. draft.supportingCast = value;
  360. break;
  361. case "conflictCore":
  362. draft.conflictCore = value;
  363. break;
  364. case "volumeOutline":
  365. draft.volumeOutline = value;
  366. break;
  367. case "constraints":
  368. draft.constraints = value;
  369. break;
  370. case "authorIntent":
  371. draft.authorIntent = value;
  372. break;
  373. case "currentFocus":
  374. draft.currentFocus = value;
  375. break;
  376. // Unknown keys are silently ignored — the LLM may emit
  377. // application-level keys we don't map to the draft struct.
  378. }
  379. }
  380. return draft;
  381. }
  382. function formatDraftForUserMessage(
  383. existingDraft: BookCreationDraft | undefined,
  384. userMessage: string,
  385. ): string {
  386. const parts: string[] = [];
  387. if (existingDraft) {
  388. parts.push("## 当前草案状态");
  389. const entries = Object.entries(existingDraft).filter(
  390. ([, v]) => v !== undefined && v !== "" && !(Array.isArray(v) && v.length === 0),
  391. );
  392. for (const [key, value] of entries) {
  393. parts.push(`- **${key}**: ${typeof value === "object" ? JSON.stringify(value) : String(value)}`);
  394. }
  395. parts.push("");
  396. }
  397. parts.push("## 用户输入");
  398. parts.push(userMessage);
  399. return parts.join("\n");
  400. }
  401. export function createInteractionToolsFromDeps(
  402. pipeline: PipelineLike,
  403. state: StateLike,
  404. hooks?: {
  405. readonly onChatTextDelta?: (text: string) => void;
  406. readonly onDraftTextDelta?: (text: string) => void;
  407. readonly onDraftRawDelta?: (text: string) => void;
  408. readonly getChatRequestOptions?: () => {
  409. readonly temperature?: number;
  410. readonly maxTokens?: number;
  411. };
  412. },
  413. ): InteractionRuntimeTools {
  414. const instrumentedPipeline = pipeline as InstrumentablePipelineLike;
  415. return {
  416. listBooks: () => state.listBooks(),
  417. developBookDraft: async (input, existingDraft) => {
  418. const concept = existingDraft?.concept ?? input;
  419. if (!instrumentedPipeline.config?.client || !instrumentedPipeline.config?.model) {
  420. // Fallback: no LLM configured
  421. return {
  422. __interaction: {
  423. responseText: "请先配置 LLM 模型,然后再创建书籍。",
  424. details: {
  425. creationDraft: {
  426. concept,
  427. missingFields: ["title", "genre", "targetChapters"],
  428. readyToCreate: false,
  429. },
  430. },
  431. },
  432. };
  433. }
  434. // Build messages - include existing draft context if present
  435. const userContent = existingDraft
  436. ? `当前草案参数:${JSON.stringify(existingDraft, null, 2)}\n\n用户输入:${input}`
  437. : input;
  438. const result = await chatWithTools(
  439. instrumentedPipeline.config.client,
  440. instrumentedPipeline.config.model,
  441. [
  442. { role: "system", content: BOOK_DRAFT_SYSTEM_PROMPT },
  443. { role: "user", content: userContent },
  444. ],
  445. [CREATE_BOOK_TOOL],
  446. { temperature: 0.4 },
  447. );
  448. // Extract tool call if present
  449. const toolCall = result.toolCalls[0];
  450. let parsedArgs: Record<string, unknown> = {};
  451. if (toolCall) {
  452. try {
  453. parsedArgs = JSON.parse(toolCall.arguments);
  454. } catch {
  455. // If parsing fails, use empty args
  456. }
  457. }
  458. // Build a draft from tool call arguments
  459. const draft: BookCreationDraft = {
  460. concept,
  461. title: (parsedArgs.title as string) ?? existingDraft?.title,
  462. genre: (parsedArgs.genre as string) ?? existingDraft?.genre,
  463. platform: (parsedArgs.platform as string) ?? existingDraft?.platform,
  464. language: (parsedArgs.language as "zh" | "en") ?? existingDraft?.language,
  465. targetChapters: (parsedArgs.targetChapters as number) ?? existingDraft?.targetChapters,
  466. chapterWordCount: (parsedArgs.chapterWordCount as number) ?? existingDraft?.chapterWordCount,
  467. blurb: (parsedArgs.brief as string) ?? existingDraft?.blurb,
  468. missingFields: [],
  469. readyToCreate: Boolean(parsedArgs.title && parsedArgs.genre && parsedArgs.platform),
  470. };
  471. return {
  472. __interaction: {
  473. responseText: result.content || "已生成建书参数,请确认或修改。",
  474. details: {
  475. creationDraft: draft,
  476. toolCall: toolCall ? { name: toolCall.name, arguments: parsedArgs } : undefined,
  477. },
  478. },
  479. };
  480. },
  481. createBook: async (input) => {
  482. const book = buildBookConfig(input);
  483. if (!pipeline.initBook) {
  484. throw new Error("Pipeline does not support shared book creation.");
  485. }
  486. await pipeline.initBook(book, {
  487. externalContext: buildCreationExternalContext(input),
  488. authorIntent: input.authorIntent,
  489. currentFocus: input.currentFocus,
  490. });
  491. return {
  492. bookId: book.id,
  493. title: book.title,
  494. __interaction: {
  495. responseText: `Created ${book.title} (${book.id}).`,
  496. details: {
  497. bookId: book.id,
  498. title: book.title,
  499. },
  500. },
  501. };
  502. },
  503. exportBook: async (bookId, options) => {
  504. const result = await exportBookToPath(state, bookId, options);
  505. return {
  506. ...result,
  507. __interaction: {
  508. responseText: `Exported ${bookId} to ${result.outputPath} (${result.chaptersExported} chapters).`,
  509. details: {
  510. outputPath: result.outputPath,
  511. chaptersExported: result.chaptersExported,
  512. totalWords: result.totalWords,
  513. format: result.format,
  514. },
  515. },
  516. };
  517. },
  518. chat: async (input, options) => {
  519. const bookLabel = options.bookId ?? "none";
  520. const chatRequestOptions = hooks?.getChatRequestOptions?.() ?? {};
  521. let response: Awaited<ReturnType<typeof chatCompletion>> | undefined;
  522. if (instrumentedPipeline.config?.client && instrumentedPipeline.config?.model) {
  523. try {
  524. response = await chatCompletion(
  525. instrumentedPipeline.config.client,
  526. instrumentedPipeline.config.model,
  527. [
  528. {
  529. role: "system",
  530. content: [
  531. "You are InkOS inside the terminal workbench.",
  532. "Respond conversationally and briefly.",
  533. "If there is no active book, help the user decide what to write next.",
  534. "If there is an active book, keep the answer grounded in that book context.",
  535. ].join(" "),
  536. },
  537. {
  538. role: "user",
  539. content: `activeBook=${bookLabel}\nautomationMode=${options.automationMode}\nmessage=${input}`,
  540. },
  541. ],
  542. {
  543. temperature: chatRequestOptions.temperature ?? 0.4,
  544. ...(chatRequestOptions.maxTokens !== undefined && { maxTokens: chatRequestOptions.maxTokens }),
  545. onTextDelta: hooks?.onChatTextDelta,
  546. },
  547. );
  548. } catch (err) {
  549. // Thinking models (e.g. kimi-k2.5) may return empty content for simple inputs.
  550. // Only swallow empty-content errors; re-throw everything else (network, auth, etc.)
  551. const msg = err instanceof Error ? err.message : "";
  552. if (!msg.includes("empty") && !msg.includes("content")) {
  553. throw err;
  554. }
  555. }
  556. }
  557. return {
  558. __interaction: {
  559. responseText: response?.content?.trim()
  560. || (options.bookId
  561. ? `I’m here. Active book is ${options.bookId}.`
  562. : "I’m here. No active book yet."),
  563. },
  564. };
  565. },
  566. writeNextChapter: (bookId) => withPipelineInteractionTelemetry(
  567. instrumentedPipeline,
  568. bookId,
  569. () => pipeline.writeNextChapter(bookId),
  570. ),
  571. reviseDraft: (bookId, chapterNumber, mode) => withPipelineInteractionTelemetry(
  572. instrumentedPipeline,
  573. bookId,
  574. () => pipeline.reviseDraft(bookId, chapterNumber, mode as ReviseMode),
  575. ),
  576. patchChapterText: async (bookId, chapterNumber, targetText, replacementText) => {
  577. const execution = await executeEditTransaction(
  578. {
  579. bookDir: (targetBookId) => state.bookDir(targetBookId),
  580. loadChapterIndex: (targetBookId) => state.loadChapterIndex(targetBookId),
  581. saveChapterIndex: (targetBookId, index) => state.saveChapterIndex(targetBookId, index),
  582. },
  583. {
  584. kind: "chapter-local-edit",
  585. bookId,
  586. chapterNumber,
  587. instruction: `Replace ${targetText} with ${replacementText}`,
  588. targetText,
  589. replacementText,
  590. },
  591. );
  592. return {
  593. __interaction: {
  594. activeChapterNumber: chapterNumber,
  595. responseText: execution.summary,
  596. },
  597. };
  598. },
  599. renameEntity: async (bookId, oldValue, newValue) => {
  600. const execution = await executeEditTransaction(
  601. {
  602. bookDir: (targetBookId) => state.bookDir(targetBookId),
  603. loadChapterIndex: (targetBookId) => state.loadChapterIndex(targetBookId),
  604. saveChapterIndex: (targetBookId, index) => state.saveChapterIndex(targetBookId, index),
  605. },
  606. {
  607. kind: "entity-rename",
  608. bookId,
  609. entityType: "character",
  610. oldValue,
  611. newValue,
  612. },
  613. );
  614. return {
  615. __interaction: {
  616. responseText: execution.summary,
  617. },
  618. };
  619. },
  620. updateCurrentFocus: async (bookId, content) => {
  621. await state.ensureControlDocuments(bookId);
  622. await writeFile(join(state.bookDir(bookId), "story", "current_focus.md"), content, "utf-8");
  623. },
  624. updateAuthorIntent: async (bookId, content) => {
  625. await state.ensureControlDocuments(bookId);
  626. await writeFile(join(state.bookDir(bookId), "story", "author_intent.md"), content, "utf-8");
  627. },
  628. writeTruthFile: async (bookId, fileName, content) => {
  629. await state.ensureControlDocuments(bookId);
  630. await writeFile(join(state.bookDir(bookId), "story", fileName), content, "utf-8");
  631. },
  632. };
  633. }