diff --git a/.gitignore b/.gitignore index 5e5a9e63..0712cd69 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist/ .DS_Store coverage/ .turbo/ +AGENTS.md diff --git a/packages/cli/src/__tests__/cli-integration.test.ts b/packages/cli/src/__tests__/cli-integration.test.ts index bbc1d8f1..71eb5847 100644 --- a/packages/cli/src/__tests__/cli-integration.test.ts +++ b/packages/cli/src/__tests__/cli-integration.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { mkdtemp, readFile, rm, stat, mkdir, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -190,4 +190,118 @@ describe("CLI integration", () => { expect(exitCode).not.toBe(0); }); }); + + describe("inkos book file", () => { + const bookId = "test-book"; + + beforeAll(async () => { + const bookDir = join(projectDir, "books", bookId); + const storyDir = join(bookDir, "story"); + await mkdir(storyDir, { recursive: true }); + await writeFile( + join(bookDir, "book.json"), + JSON.stringify({ + id: bookId, + title: "Test Book", + platform: "tomato", + genre: "xuanhuan", + status: "active", + targetChapters: 100, + chapterWordCount: 3000, + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + }, null, 2), + "utf-8", + ); + await writeFile(join(storyDir, "story_bible.md"), "# Original Bible", "utf-8"); + await writeFile( + join(storyDir, "book_rules.md"), + [ + "---", + 'version: "1.0"', + "protagonist:", + " name: 林烬", + " personalityLock: [强势冷静]", + " behavioralConstraints: [不圣母]", + "genreLock:", + " primary: xuanhuan", + " forbidden: [都市文风]", + "prohibitions:", + " - 主角关键时刻心软", + "chapterTypesOverride: []", + "fatigueWordsOverride: []", + "additionalAuditDimensions: []", + "enableFullCastTracking: false", + "---", + "", + "## 叙事视角", + "第三人称近距离", + ].join("\n"), + "utf-8", + ); + }); + + it("lists editable story files", () => { + const output = run(["book", "file", "list", "--json"]); + const data = JSON.parse(output); + expect(data.files).toContain("story_bible"); + expect(data.files).toContain("book_rules"); + }); + + it("shows a story file", () => { + const output = run(["book", "file", "show", bookId, "story_bible"]); + expect(output).toContain("Original Bible"); + }); + + it("updates a story file", async () => { + const output = run([ + "book", + "file", + "set", + bookId, + "story_bible", + "--content", + "# Updated Bible", + ]); + expect(output).toContain(`Updated story_bible for ${bookId}.`); + + const content = await readFile( + join(projectDir, "books", bookId, "story", "story_bible.md"), + "utf-8", + ); + expect(content).toBe("# Updated Bible"); + }); + }); + + describe("inkos agent session utilities", () => { + beforeAll(async () => { + const sessionsDir = join(projectDir, ".inkos", "agent-sessions"); + await mkdir(sessionsDir, { recursive: true }); + await writeFile( + join(sessionsDir, "default.json"), + JSON.stringify([ + { role: "user", content: "上一轮说了什么?" }, + { role: "assistant", content: "上一轮我们确认了主角设定。" }, + ], null, 2), + "utf-8", + ); + }); + + it("shows saved agent history", () => { + const output = run(["agent", "history"]); + expect(output).toContain("Session: default"); + expect(output).toContain("上一轮我们确认了主角设定"); + }); + + it("lists saved agent sessions", () => { + const output = run(["agent", "sessions"]); + expect(output).toContain("default"); + expect(output).toContain("messages: 2"); + }); + + it("clears a saved agent session", () => { + const output = run(["agent", "clear"]); + expect(output).toContain('Cleared session "default".'); + }); + }); }); diff --git a/packages/cli/src/commands/agent.ts b/packages/cli/src/commands/agent.ts index 15571427..5468bda4 100644 --- a/packages/cli/src/commands/agent.ts +++ b/packages/cli/src/commands/agent.ts @@ -1,5 +1,5 @@ import { Command } from "commander"; -import { runAgentLoop } from "@actalk/inkos-core"; +import { runAgentLoop, StateManager, type ToolCall } from "@actalk/inkos-core"; import { loadConfig, createClient, findProjectRoot, resolveContext, log, logError } from "../utils.js"; export const agentCommand = new Command("agent") @@ -8,6 +8,8 @@ export const agentCommand = new Command("agent") .option("--context ", "Additional context (natural language)") .option("--context-file ", "Read additional context from file") .option("--max-turns ", "Maximum agent turns", "20") + .option("--session ", "Conversation session ID", "default") + .option("--no-memory", "Disable loading/saving conversation history") .option("--json", "Output JSON (suppress progress messages)") .option("--quiet", "Suppress tool call logs") .action(async (instruction: string, opts) => { @@ -15,7 +17,13 @@ export const agentCommand = new Command("agent") const config = await loadConfig(); const client = createClient(config); const root = findProjectRoot(); + const state = new StateManager(root); const context = await resolveContext(opts); + const sessionId = opts.session as string; + const useMemory = opts.memory as boolean; + const history = useMemory + ? await state.loadAgentSession(sessionId) + : []; const fullInstruction = context ? `${instruction}\n\n补充信息:${context}` @@ -23,6 +31,10 @@ export const agentCommand = new Command("agent") const maxTurns = parseInt(opts.maxTurns, 10); + if (!opts.json && useMemory) { + log(`[session] ${sessionId} | resumed messages: ${history.length}`); + } + const result = await runAgentLoop( { client, @@ -32,27 +44,33 @@ export const agentCommand = new Command("agent") fullInstruction, { maxTurns, + sessionId, + useMemory, onToolCall: opts.quiet || opts.json ? undefined - : (name, args) => { + : (name: string, args: Record) => { log(` [tool] ${name}(${JSON.stringify(args)})`); }, onToolResult: opts.quiet || opts.json ? undefined - : (name, result) => { + : (name: string, result: string) => { const preview = result.length > 200 ? `${result.slice(0, 200)}...` : result; log(` [result] ${name} → ${preview}`); }, onMessage: opts.json ? undefined - : (content) => { + : (content: string) => { log(`\n${content}`); }, }, ); if (opts.json) { - log(JSON.stringify({ result })); + log(JSON.stringify({ + result, + sessionId: useMemory ? sessionId : null, + resumedMessages: history.length, + })); } } catch (e) { if (opts.json) { @@ -63,3 +81,97 @@ export const agentCommand = new Command("agent") process.exit(1); } }); + +agentCommand + .command("history") + .description("Show saved conversation history") + .argument("[session-id]", "Session ID", "default") + .option("--json", "Output JSON") + .action(async (sessionId: string, opts) => { + try { + const state = new StateManager(findProjectRoot()); + const messages = await state.loadAgentSession(sessionId); + + if (opts.json) { + log(JSON.stringify({ sessionId, messages }, null, 2)); + return; + } + + if (messages.length === 0) { + log(`No saved history for session \"${sessionId}\".`); + return; + } + + log(`Session: ${sessionId}`); + for (const message of messages) { + if (message.role === "user") { + log(`\n[user]\n${message.content}`); + continue; + } + if (message.role === "assistant") { + log(`\n[assistant]\n${message.content ?? ""}`); + if (message.toolCalls?.length) { + log(`[tool-calls] ${message.toolCalls.map((tool: ToolCall) => tool.name).join(", ")}`); + } + continue; + } + if (message.role === "tool") { + log(`\n[tool:${message.toolCallId}]\n${message.content}`); + } + } + } catch (e) { + if (opts.json) { + log(JSON.stringify({ error: String(e) })); + } else { + logError(`Failed to read agent history: ${e}`); + } + process.exit(1); + } + }); + +agentCommand + .command("sessions") + .description("List saved conversation sessions") + .option("--json", "Output JSON") + .action(async (opts) => { + try { + const state = new StateManager(findProjectRoot()); + const sessions = await state.listAgentSessions(); + + if (opts.json) { + log(JSON.stringify({ sessions }, null, 2)); + return; + } + + if (sessions.length === 0) { + log("No saved agent sessions."); + return; + } + + for (const session of sessions) { + log(`${session.id} | messages: ${session.messageCount} | updated: ${session.updatedAt}`); + } + } catch (e) { + if (opts.json) { + log(JSON.stringify({ error: String(e) })); + } else { + logError(`Failed to list agent sessions: ${e}`); + } + process.exit(1); + } + }); + +agentCommand + .command("clear") + .description("Delete saved conversation history for a session") + .argument("[session-id]", "Session ID", "default") + .action(async (sessionId: string) => { + try { + const state = new StateManager(findProjectRoot()); + await state.deleteAgentSession(sessionId); + log(`Cleared session \"${sessionId}\".`); + } catch (e) { + logError(`Failed to clear agent session: ${e}`); + process.exit(1); + } + }); diff --git a/packages/cli/src/commands/book.ts b/packages/cli/src/commands/book.ts index f92f4ace..a426ff12 100644 --- a/packages/cli/src/commands/book.ts +++ b/packages/cli/src/commands/book.ts @@ -1,5 +1,5 @@ import { Command } from "commander"; -import { PipelineRunner, StateManager, type BookConfig } from "@actalk/inkos-core"; +import { PipelineRunner, StateManager, type BookConfig, type EditableStoryFileKey } from "@actalk/inkos-core"; import { loadConfig, createClient, findProjectRoot, resolveContext, resolveBookId, log, logError } from "../utils.js"; export const bookCommand = new Command("book") @@ -186,3 +186,126 @@ bookCommand process.exit(1); } }); + +const bookFileCommand = bookCommand + .command("file") + .description("Read or update a book story/configuration file"); + +bookFileCommand + .command("list") + .description("List editable story files") + .option("--json", "Output JSON") + .action(async (opts) => { + try { + const state = new StateManager(findProjectRoot()); + const files = state.listEditableStoryFiles(); + + if (opts.json) { + log(JSON.stringify({ files }, null, 2)); + } else { + for (const file of files) { + log(file); + } + } + } catch (e) { + if (opts.json) { + log(JSON.stringify({ error: String(e) })); + } else { + logError(`Failed to list story files: ${e}`); + } + process.exit(1); + } + }); + +bookFileCommand + .command("show") + .description("Show one editable story file: show [book-id] ") + .argument("", "Book ID (optional) and file key") + .option("--json", "Output JSON") + .action(async (args: ReadonlyArray, opts) => { + try { + const root = findProjectRoot(); + let bookIdArg: string | undefined; + let fileKey: string; + + if (args.length === 1) { + fileKey = args[0]!; + } else if (args.length === 2) { + bookIdArg = args[0]; + fileKey = args[1]!; + } else { + throw new Error("Usage: inkos book file show [book-id] "); + } + + const bookId = await resolveBookId(bookIdArg, root); + const state = new StateManager(root); + const content = await state.readStoryFile(bookId, fileKey as EditableStoryFileKey); + + if (opts.json) { + log(JSON.stringify({ bookId, fileKey, content }, null, 2)); + } else { + log(content); + } + } catch (e) { + if (opts.json) { + log(JSON.stringify({ error: String(e) })); + } else { + logError(`Failed to read story file: ${e}`); + } + process.exit(1); + } + }); + +bookFileCommand + .command("set") + .description("Replace one editable story file: set [book-id] ") + .argument("", "Book ID (optional) and file key") + .option("--content ", "Replacement text") + .option("--content-file ", "Read replacement text from file") + .option("--json", "Output JSON") + .action(async (args: ReadonlyArray, opts) => { + try { + const root = findProjectRoot(); + let bookIdArg: string | undefined; + let fileKey: string; + + if (args.length === 1) { + fileKey = args[0]!; + } else if (args.length === 2) { + bookIdArg = args[0]; + fileKey = args[1]!; + } else { + throw new Error("Usage: inkos book file set [book-id] "); + } + + const bookId = await resolveBookId(bookIdArg, root); + const state = new StateManager(root); + const content = await resolveContext({ + context: opts.content, + contextFile: opts.contentFile, + }); + + if (!content) { + throw new Error("No replacement content provided. Use --content, --content-file, or pipe stdin."); + } + + await state.writeStoryFile( + bookId, + fileKey as EditableStoryFileKey, + content, + ); + + if (opts.json) { + log(JSON.stringify({ bookId, fileKey, status: "updated" }, null, 2)); + } else { + log(`Updated ${fileKey} for ${bookId}.`); + } + } catch (e) { + if (opts.json) { + log(JSON.stringify({ error: String(e) })); + } else { + logError(`Failed to update story file: ${e}`); + } + process.exit(1); + } + }); diff --git a/packages/core/src/__tests__/state-manager.test.ts b/packages/core/src/__tests__/state-manager.test.ts index cd1e75b0..a95f5821 100644 --- a/packages/core/src/__tests__/state-manager.test.ts +++ b/packages/core/src/__tests__/state-manager.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { StateManager } from "../state/manager.js"; import type { BookConfig } from "../models/book.js"; import type { ChapterMeta } from "../models/chapter.js"; +import type { AgentMessage } from "../llm/provider.js"; describe("StateManager", () => { let tempDir: string; @@ -363,4 +364,64 @@ describe("StateManager", () => { ); }); }); + + describe("story file helpers", () => { + it("writes and reads editable story files", async () => { + await manager.writeStoryFile("story-book", "story_bible", "# Story Bible"); + + const content = await manager.readStoryFile("story-book", "story_bible"); + expect(content).toBe("# Story Bible"); + }); + + it("validates book_rules before saving", async () => { + await expect( + manager.writeStoryFile("story-book", "book_rules", "not valid book rules"), + ).rejects.toThrow(); + }); + + it("lists editable story file keys", () => { + expect(manager.listEditableStoryFiles()).toContain("book_rules"); + expect(manager.listEditableStoryFiles()).toContain("story_bible"); + }); + }); + + describe("agent sessions", () => { + const sessionMessages: ReadonlyArray = [ + { role: "user", content: "继续写下一章" }, + { role: "assistant", content: "好的,我先检查当前状态。" }, + ]; + + it("round-trips agent session history", async () => { + await manager.saveAgentSession("default", sessionMessages); + + const loaded = await manager.loadAgentSession("default"); + expect(loaded).toEqual(sessionMessages); + }); + + it("returns empty history for a missing session", async () => { + const loaded = await manager.loadAgentSession("missing-session"); + expect(loaded).toEqual([]); + }); + + it("lists saved agent sessions with message counts", async () => { + await manager.saveAgentSession("alpha", sessionMessages); + await manager.saveAgentSession("beta", [ + ...sessionMessages, + { role: "tool", toolCallId: "tool-1", content: '{"ok":true}' }, + ]); + + const sessions = await manager.listAgentSessions(); + expect(sessions.map((session) => session.id)).toContain("alpha"); + expect(sessions.map((session) => session.id)).toContain("beta"); + expect(sessions.find((session) => session.id === "beta")?.messageCount).toBe(3); + }); + + it("deletes saved agent sessions", async () => { + await manager.saveAgentSession("cleanup", sessionMessages); + await manager.deleteAgentSession("cleanup"); + + const loaded = await manager.loadAgentSession("cleanup"); + expect(loaded).toEqual([]); + }); + }); }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7301033a..fd8cb56e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -38,7 +38,7 @@ export { runAgentLoop, AGENT_TOOLS as AGENT_TOOLS, type AgentLoopOptions } from export { detectChapter, detectAndRewrite, loadDetectionHistory, type DetectChapterResult, type DetectAndRewriteResult } from "./pipeline/detection-runner.js"; // State -export { StateManager } from "./state/manager.js"; +export { StateManager, EDITABLE_STORY_FILES, type EditableStoryFileKey, type AgentSessionInfo } from "./state/manager.js"; // Notify export { dispatchNotification, dispatchWebhookEvent, type NotifyMessage } from "./notify/dispatcher.js"; diff --git a/packages/core/src/pipeline/agent.ts b/packages/core/src/pipeline/agent.ts index c2725539..19fa8152 100644 --- a/packages/core/src/pipeline/agent.ts +++ b/packages/core/src/pipeline/agent.ts @@ -2,6 +2,7 @@ import { chatWithTools, type AgentMessage, type ToolDefinition } from "../llm/pr import { PipelineRunner, type PipelineConfig } from "./runner.js"; import type { Platform, Genre } from "../models/book.js"; import type { ReviseMode } from "../agents/reviser.js"; +import type { EditableStoryFileKey } from "../state/manager.js"; /** Tool definitions for the agent loop. */ const TOOLS: ReadonlyArray = [ @@ -86,6 +87,63 @@ const TOOLS: ReadonlyArray = [ required: ["bookId"], }, }, + { + name: "read_story_file", + description: "读取某个底层设定文件或真相文件的完整内容。适合查看 story_bible、book_rules、current_state 等单个文件。", + parameters: { + type: "object", + properties: { + bookId: { type: "string", description: "书籍ID" }, + fileKey: { + type: "string", + enum: [ + "story_bible", + "volume_outline", + "book_rules", + "current_state", + "pending_hooks", + "particle_ledger", + "subplot_board", + "emotional_arcs", + "character_matrix", + "style_guide", + "parent_canon", + ], + description: "要读取的文件键名", + }, + }, + required: ["bookId", "fileKey"], + }, + }, + { + name: "update_story_file", + description: "直接覆盖更新某个底层设定文件或真相文件。适合修改 story_bible、book_rules、current_state 等。", + parameters: { + type: "object", + properties: { + bookId: { type: "string", description: "书籍ID" }, + fileKey: { + type: "string", + enum: [ + "story_bible", + "volume_outline", + "book_rules", + "current_state", + "pending_hooks", + "particle_ledger", + "subplot_board", + "emotional_arcs", + "character_matrix", + "style_guide", + "parent_canon", + ], + description: "要更新的文件键名", + }, + content: { type: "string", description: "更新后的完整文件内容" }, + }, + required: ["bookId", "fileKey", "content"], + }, + }, { name: "list_books", description: "列出所有书籍。", @@ -149,6 +207,8 @@ export interface AgentLoopOptions { readonly onToolResult?: (name: string, result: string) => void; readonly onMessage?: (content: string) => void; readonly maxTurns?: number; + readonly sessionId?: string; + readonly useMemory?: boolean; } export async function runAgentLoop( @@ -159,6 +219,11 @@ export async function runAgentLoop( const pipeline = new PipelineRunner(config); const { StateManager } = await import("../state/manager.js"); const state = new StateManager(config.projectRoot); + const useMemory = options?.useMemory ?? true; + const sessionId = useMemory ? (options?.sessionId ?? "default") : undefined; + const history = sessionId + ? await state.loadAgentSession(sessionId) + : []; const messages: AgentMessage[] = [ { @@ -172,6 +237,8 @@ export async function runAgentLoop( | list_books | 列出所有书 | | get_book_status | 查看书的章数、字数、审计状态 | | read_truth_files | 读取长期记忆(状态卡、资源账本、伏笔池)和设定(世界观、卷纲、本书规则) | +| read_story_file | 读取单个底层设定文件或真相文件 | +| update_story_file | 直接覆盖更新单个底层设定文件或真相文件 | | create_book | 建书,生成世界观、卷纲、本书规则(自动加载题材 genre profile) | | write_draft | 写一章草稿(自动加载 genre profile + book_rules) | | audit_chapter | 审计章节(32维度,按题材条件启用,含AI痕迹+敏感词检测) | @@ -203,13 +270,25 @@ export async function runAgentLoop( - 用户提供了题材/创意但没说要扫描市场 → 跳过 scan_market,直接 create_book - 用户说了书名/bookId → 直接操作,不需要先 list_books +- 用户明确要求修改设定文件时,先 read_story_file 确认当前内容,再用 update_story_file 覆盖写回 - 每完成一步,简要汇报进展 - 仿写流程:用户提供参考文本 → import_style → 生成 style_guide.md,后续写作自动参照 - 番外流程:先 create_book 建番外书 → import_canon 导入正传正典 → 然后正常 write_draft`, }, + ...history, { role: "user", content: instruction }, ]; + const persistSession = async () => { + if (!sessionId) return; + await state.saveAgentSession( + sessionId, + messages.filter((message) => message.role !== "system"), + ); + }; + + await persistSession(); + const maxTurns = options?.maxTurns ?? 20; let lastAssistantMessage = ""; @@ -222,6 +301,7 @@ export async function runAgentLoop( content: result.content || null, ...(result.toolCalls.length > 0 ? { toolCalls: result.toolCalls } : {}), }); + await persistSession(); if (result.content) { lastAssistantMessage = result.content; @@ -244,6 +324,7 @@ export async function runAgentLoop( options?.onToolResult?.(toolCall.name, toolResult); messages.push({ role: "tool" as const, toolCallId: toolCall.id, content: toolResult }); + await persistSession(); } } @@ -325,6 +406,30 @@ async function executeTool( return JSON.stringify(result); } + case "read_story_file": { + const fileKey = args.fileKey as EditableStoryFileKey; + const content = await state.readStoryFile(args.bookId as string, fileKey); + return JSON.stringify({ + bookId: args.bookId, + fileKey, + content, + }); + } + + case "update_story_file": { + const fileKey = args.fileKey as EditableStoryFileKey; + await state.writeStoryFile( + args.bookId as string, + fileKey, + args.content as string, + ); + return JSON.stringify({ + bookId: args.bookId, + fileKey, + status: "updated", + }); + } + case "read_truth_files": { const result = await pipeline.readTruthFiles(args.bookId as string); return JSON.stringify(result); diff --git a/packages/core/src/state/manager.ts b/packages/core/src/state/manager.ts index 362faa3c..6109a488 100644 --- a/packages/core/src/state/manager.ts +++ b/packages/core/src/state/manager.ts @@ -2,10 +2,66 @@ import { readFile, writeFile, mkdir, readdir, stat, unlink } from "node:fs/promi import { join } from "node:path"; import type { BookConfig } from "../models/book.js"; import type { ChapterMeta } from "../models/chapter.js"; +import type { AgentMessage } from "../llm/provider.js"; +import { parseBookRules } from "../models/book-rules.js"; + +export const EDITABLE_STORY_FILES = { + story_bible: "story_bible.md", + volume_outline: "volume_outline.md", + book_rules: "book_rules.md", + current_state: "current_state.md", + pending_hooks: "pending_hooks.md", + particle_ledger: "particle_ledger.md", + subplot_board: "subplot_board.md", + emotional_arcs: "emotional_arcs.md", + character_matrix: "character_matrix.md", + style_guide: "style_guide.md", + parent_canon: "parent_canon.md", +} as const; + +export type EditableStoryFileKey = keyof typeof EDITABLE_STORY_FILES; + +export interface AgentSessionInfo { + readonly id: string; + readonly messageCount: number; + readonly updatedAt: string; +} export class StateManager { constructor(private readonly projectRoot: string) {} + private assertStoryFileKey(fileKey: string): asserts fileKey is EditableStoryFileKey { + if (!(fileKey in EDITABLE_STORY_FILES)) { + throw new Error( + `Unsupported story file key: ${fileKey}. Supported keys: ${Object.keys(EDITABLE_STORY_FILES).join(", ")}`, + ); + } + } + + private get agentSessionsDir(): string { + return join(this.projectRoot, ".inkos", "agent-sessions"); + } + + private sessionPath(sessionId: string): string { + return join(this.agentSessionsDir, `${encodeURIComponent(sessionId)}.json`); + } + + private resolveStoryFile(bookId: string, fileKey: EditableStoryFileKey): string { + return join(this.bookDir(bookId), "story", EDITABLE_STORY_FILES[fileKey]); + } + + private validateStoryFileContent(fileKey: EditableStoryFileKey, content: string): void { + if (!content.trim()) { + throw new Error(`Story file "${fileKey}" cannot be empty`); + } + if (fileKey === "book_rules") { + if (!/---\s*\n[\s\S]*?\n---/.test(content)) { + throw new Error("book_rules must include YAML frontmatter wrapped in --- markers"); + } + parseBookRules(content); + } + } + async acquireBookLock(bookId: string): Promise<() => Promise> { const lockPath = join(this.bookDir(bookId), ".write.lock"); try { @@ -115,6 +171,96 @@ export class StateManager { ); } + async readStoryFile(bookId: string, fileKey: EditableStoryFileKey): Promise { + this.assertStoryFileKey(fileKey); + const path = this.resolveStoryFile(bookId, fileKey); + return readFile(path, "utf-8"); + } + + async writeStoryFile( + bookId: string, + fileKey: EditableStoryFileKey, + content: string, + ): Promise { + this.assertStoryFileKey(fileKey); + this.validateStoryFileContent(fileKey, content); + const storyDir = join(this.bookDir(bookId), "story"); + await mkdir(storyDir, { recursive: true }); + await writeFile(this.resolveStoryFile(bookId, fileKey), content, "utf-8"); + } + + listEditableStoryFiles(): ReadonlyArray { + return Object.keys(EDITABLE_STORY_FILES) as EditableStoryFileKey[]; + } + + async loadAgentSession(sessionId: string): Promise> { + try { + const raw = await readFile(this.sessionPath(sessionId), "utf-8"); + const parsed = JSON.parse(raw) as unknown; + if (!Array.isArray(parsed)) { + throw new Error(`Agent session "${sessionId}" is invalid`); + } + return parsed as ReadonlyArray; + } catch (error) { + const code = + typeof error === "object" && error !== null && "code" in error + ? String((error as { code?: unknown }).code) + : undefined; + if (code === "ENOENT") { + return []; + } + throw error; + } + } + + async saveAgentSession( + sessionId: string, + messages: ReadonlyArray, + ): Promise { + await mkdir(this.agentSessionsDir, { recursive: true }); + await writeFile( + this.sessionPath(sessionId), + JSON.stringify(messages, null, 2), + "utf-8", + ); + } + + async deleteAgentSession(sessionId: string): Promise { + try { + await unlink(this.sessionPath(sessionId)); + } catch { + // ignore missing session file + } + } + + async listAgentSessions(): Promise> { + try { + const entries = await readdir(this.agentSessionsDir); + const sessions = await Promise.all( + entries + .filter((entry) => entry.endsWith(".json")) + .map(async (entry) => { + const id = decodeURIComponent(entry.replace(/\.json$/, "")); + const path = join(this.agentSessionsDir, entry); + const [raw, fileStat] = await Promise.all([ + readFile(path, "utf-8"), + stat(path), + ]); + const parsed = JSON.parse(raw) as unknown; + const messageCount = Array.isArray(parsed) ? parsed.length : 0; + return { + id, + messageCount, + updatedAt: fileStat.mtime.toISOString(), + } satisfies AgentSessionInfo; + }), + ); + return sessions.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)); + } catch { + return []; + } + } + async snapshotState(bookId: string, chapterNumber: number): Promise { const storyDir = join(this.bookDir(bookId), "story"); const snapshotDir = join(storyDir, "snapshots", String(chapterNumber));