Просмотр исходного кода

feat(tui): persist project sessions and route control flow

Ma 7 месяцев назад
Родитель
Сommit
762494e233

+ 79 - 0
packages/cli/src/__tests__/interaction-tools.test.ts

@@ -0,0 +1,79 @@
+import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
+import { createInteractionToolsFromDeps } from "../tui/tools.js";
+
+const chapterResult = {
+  chapterNumber: 1,
+  title: "Draft",
+  wordCount: 1200,
+  revised: false,
+  status: "ready-for-review" as const,
+  auditResult: {
+    passed: true,
+    issues: [],
+    summary: "ok",
+  },
+};
+
+const reviseResult = {
+  chapterNumber: 3,
+  wordCount: 1200,
+  fixedIssues: [],
+  applied: true,
+  status: "ready-for-review" as const,
+};
+
+let projectRoot: string;
+
+describe("interaction tools adapter", () => {
+  beforeAll(async () => {
+    projectRoot = await mkdtemp(join(tmpdir(), "inkos-interaction-tools-"));
+    await mkdir(join(projectRoot, "books", "harbor", "story"), { recursive: true });
+  });
+
+  afterAll(async () => {
+    // tmpdir cleanup omitted
+  });
+
+  it("delegates writeNextChapter and reviseDraft to the pipeline", async () => {
+    const pipeline = {
+      writeNextChapter: vi.fn(async () => chapterResult),
+      reviseDraft: vi.fn(async () => reviseResult),
+    };
+    const state = {
+      ensureControlDocuments: vi.fn(async () => {}),
+      bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
+    };
+
+    const tools = createInteractionToolsFromDeps(projectRoot, pipeline, state);
+
+    await tools.writeNextChapter("harbor");
+    await tools.reviseDraft("harbor", 3, "rewrite");
+
+    expect(pipeline.writeNextChapter).toHaveBeenCalledWith("harbor");
+    expect(pipeline.reviseDraft).toHaveBeenCalledWith("harbor", 3, "rewrite");
+  });
+
+  it("writes current_focus and author_intent through the canonical story paths", async () => {
+    const pipeline = {
+      writeNextChapter: vi.fn(async () => chapterResult),
+      reviseDraft: vi.fn(async () => reviseResult),
+    };
+    const state = {
+      ensureControlDocuments: vi.fn(async () => {}),
+      bookDir: vi.fn((bookId: string) => join(projectRoot, "books", bookId)),
+    };
+
+    const tools = createInteractionToolsFromDeps(projectRoot, pipeline, state);
+
+    await tools.updateCurrentFocus("harbor", "# Current Focus\n\nBring focus back to the old case.\n");
+    await tools.updateAuthorIntent("harbor", "# Author Intent\n\nWrite a cold harbor mystery.\n");
+
+    await expect(readFile(join(projectRoot, "books", "harbor", "story", "current_focus.md"), "utf-8"))
+      .resolves.toContain("Bring focus back to the old case");
+    await expect(readFile(join(projectRoot, "books", "harbor", "story", "author_intent.md"), "utf-8"))
+      .resolves.toContain("Write a cold harbor mystery");
+  });
+});

+ 58 - 0
packages/cli/src/__tests__/tui-control-flow.test.ts

@@ -0,0 +1,58 @@
+import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
+import { createProjectSession, persistProjectSession } from "../tui/session-store.js";
+import { processTuiInput } from "../tui/app.js";
+
+let projectRoot: string;
+
+describe("tui control flow", () => {
+  beforeAll(async () => {
+    projectRoot = await mkdtemp(join(tmpdir(), "inkos-tui-control-"));
+    await mkdir(join(projectRoot, "books", "harbor"), { recursive: true });
+    await writeFile(join(projectRoot, "books", "harbor", "book.json"), "{}", "utf-8");
+  });
+
+  afterAll(async () => {
+    // tmpdir cleanup omitted
+  });
+
+  it("routes continue through the active project book", async () => {
+    await persistProjectSession(projectRoot, {
+      ...createProjectSession(projectRoot),
+      activeBookId: "harbor",
+    });
+
+    const tools = {
+      writeNextChapter: vi.fn(async () => ({ ok: true })),
+      reviseDraft: vi.fn(async () => ({ ok: true })),
+      updateCurrentFocus: vi.fn(async () => ({ ok: true })),
+      updateAuthorIntent: vi.fn(async () => ({ ok: true })),
+    };
+
+    const result = await processTuiInput(projectRoot, "continue", tools);
+
+    expect(tools.writeNextChapter).toHaveBeenCalledWith("harbor");
+    expect(result.session.activeBookId).toBe("harbor");
+    expect(result.session.currentExecution?.status).toBe("completed");
+  });
+
+  it("routes focus updates through the interaction runtime", async () => {
+    await persistProjectSession(projectRoot, {
+      ...createProjectSession(projectRoot),
+      activeBookId: "harbor",
+    });
+
+    const tools = {
+      writeNextChapter: vi.fn(async () => ({ ok: true })),
+      reviseDraft: vi.fn(async () => ({ ok: true })),
+      updateCurrentFocus: vi.fn(async () => ({ ok: true })),
+      updateAuthorIntent: vi.fn(async () => ({ ok: true })),
+    };
+
+    await processTuiInput(projectRoot, "把 focus 拉回旧案线", tools);
+
+    expect(tools.updateCurrentFocus).toHaveBeenCalledWith("harbor", "把 focus 拉回旧案线");
+  });
+});

+ 81 - 0
packages/cli/src/__tests__/tui-session-store.test.ts

@@ -0,0 +1,81 @@
+import { mkdtemp, mkdir, readFile, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import {
+  createProjectSession,
+  loadProjectSession,
+  persistProjectSession,
+  resolveSessionActiveBook,
+} from "../tui/session-store.js";
+
+let projectRoot: string;
+
+describe("tui session store", () => {
+  beforeAll(async () => {
+    projectRoot = await mkdtemp(join(tmpdir(), "inkos-tui-session-"));
+    await mkdir(join(projectRoot, "books"), { recursive: true });
+  });
+
+  afterAll(async () => {
+    // no cleanup needed, tmpdir
+  });
+
+  it("creates a default project session", () => {
+    const session = createProjectSession(projectRoot);
+    expect(session.projectRoot).toBe(projectRoot);
+    expect(session.automationMode).toBe("semi");
+    expect(session.messages).toEqual([]);
+  });
+
+  it("persists and reloads the session", async () => {
+    const session = {
+      ...createProjectSession(projectRoot),
+      activeBookId: "night-harbor",
+      automationMode: "auto" as const,
+    };
+
+    await persistProjectSession(projectRoot, session);
+    const reloaded = await loadProjectSession(projectRoot);
+
+    expect(reloaded.activeBookId).toBe("night-harbor");
+    expect(reloaded.automationMode).toBe("auto");
+  });
+
+  it("resolves active book from session when it still exists", async () => {
+    await writeFile(join(projectRoot, "books", "night-harbor", "book.json"), "{}", "utf-8").catch(async () => {
+      await mkdir(join(projectRoot, "books", "night-harbor"), { recursive: true });
+      await writeFile(join(projectRoot, "books", "night-harbor", "book.json"), "{}", "utf-8");
+    });
+
+    const session = {
+      ...createProjectSession(projectRoot),
+      activeBookId: "night-harbor",
+    };
+
+    expect(await resolveSessionActiveBook(projectRoot, session)).toBe("night-harbor");
+  });
+
+  it("falls back to the only book in the project", async () => {
+    await mkdir(join(projectRoot, "books", "single-book"), { recursive: true });
+    await writeFile(join(projectRoot, "books", "single-book", "book.json"), "{}", "utf-8");
+
+    const singleRoot = await mkdtemp(join(tmpdir(), "inkos-tui-single-"));
+    await mkdir(join(singleRoot, "books", "single-book"), { recursive: true });
+    await writeFile(join(singleRoot, "books", "single-book", "book.json"), "{}", "utf-8");
+
+    const session = createProjectSession(singleRoot);
+    expect(await resolveSessionActiveBook(singleRoot, session)).toBe("single-book");
+  });
+
+  it("returns undefined when multiple books exist and no valid active binding is stored", async () => {
+    const multiRoot = await mkdtemp(join(tmpdir(), "inkos-tui-multi-"));
+    await mkdir(join(multiRoot, "books", "book-a"), { recursive: true });
+    await mkdir(join(multiRoot, "books", "book-b"), { recursive: true });
+    await writeFile(join(multiRoot, "books", "book-a", "book.json"), "{}", "utf-8");
+    await writeFile(join(multiRoot, "books", "book-b", "book.json"), "{}", "utf-8");
+
+    const session = createProjectSession(multiRoot);
+    expect(await resolveSessionActiveBook(multiRoot, session)).toBeUndefined();
+  });
+});

+ 50 - 6
packages/cli/src/tui/app.ts

@@ -1,7 +1,19 @@
 import { basename } from "node:path";
 import readline from "node:readline/promises";
-import type { AutomationMode } from "@actalk/inkos-core";
-import type { ExecutionStatus } from "@actalk/inkos-core";
+import {
+  routeNaturalLanguageIntent,
+  runInteractionRequest,
+  type AutomationMode,
+  type ExecutionStatus,
+  type InteractionRuntimeTools,
+} from "@actalk/inkos-core";
+import {
+  createProjectSession,
+  loadProjectSession,
+  persistProjectSession,
+  resolveSessionActiveBook,
+} from "./session-store.js";
+import { createInteractionTools } from "./tools.js";
 
 export interface TuiFrameState {
   readonly projectName: string;
@@ -23,11 +35,39 @@ export function renderTuiFrame(state: TuiFrameState): string {
   return lines.join("\n");
 }
 
-export async function launchTui(projectRoot: string): Promise<void> {
+export async function processTuiInput(
+  projectRoot: string,
+  input: string,
+  tools: InteractionRuntimeTools,
+) {
+  const session = await loadProjectSession(projectRoot);
+  const activeBookId = await resolveSessionActiveBook(projectRoot, session);
+  const boundSession = activeBookId && session.activeBookId !== activeBookId
+    ? { ...session, activeBookId }
+    : session;
+  const request = routeNaturalLanguageIntent(input, {
+    activeBookId: boundSession.activeBookId,
+  });
+  const result = await runInteractionRequest({
+    session: boundSession,
+    request,
+    tools,
+  });
+  await persistProjectSession(projectRoot, result.session);
+  return result;
+}
+
+export async function launchTui(
+  projectRoot: string,
+  tools?: InteractionRuntimeTools,
+): Promise<void> {
+  const session = await loadProjectSession(projectRoot);
+  const activeBookId = await resolveSessionActiveBook(projectRoot, session);
   const frame = renderTuiFrame({
     projectName: basename(projectRoot),
-    automationMode: "semi",
-    status: "idle",
+    activeBookTitle: activeBookId,
+    automationMode: session.automationMode,
+    status: session.currentExecution?.status ?? "idle",
   });
 
   process.stdout.write(frame);
@@ -42,7 +82,11 @@ export async function launchTui(projectRoot: string): Promise<void> {
   });
 
   try {
-    await rl.question("");
+    const input = await rl.question("");
+    if (!input.trim()) {
+      return;
+    }
+    await processTuiInput(projectRoot, input, tools ?? await createInteractionTools(projectRoot));
   } finally {
     rl.close();
   }

+ 59 - 0
packages/cli/src/tui/session-store.ts

@@ -0,0 +1,59 @@
+import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { InteractionSessionSchema, type InteractionSession } from "@actalk/inkos-core";
+
+const SESSION_DIR = ".inkos";
+const SESSION_FILE = "session.json";
+
+function sessionPath(projectRoot: string): string {
+  return join(projectRoot, SESSION_DIR, SESSION_FILE);
+}
+
+export function createProjectSession(projectRoot: string): InteractionSession {
+  return InteractionSessionSchema.parse({
+    sessionId: `${Date.now()}`,
+    projectRoot,
+    automationMode: "semi",
+    messages: [],
+  });
+}
+
+export async function loadProjectSession(projectRoot: string): Promise<InteractionSession> {
+  try {
+    const raw = await readFile(sessionPath(projectRoot), "utf-8");
+    return InteractionSessionSchema.parse(JSON.parse(raw));
+  } catch {
+    return createProjectSession(projectRoot);
+  }
+}
+
+export async function persistProjectSession(
+  projectRoot: string,
+  session: InteractionSession,
+): Promise<void> {
+  const dir = join(projectRoot, SESSION_DIR);
+  await mkdir(dir, { recursive: true });
+  await writeFile(sessionPath(projectRoot), JSON.stringify(session, null, 2), "utf-8");
+}
+
+export async function resolveSessionActiveBook(
+  projectRoot: string,
+  session: InteractionSession,
+): Promise<string | undefined> {
+  const booksDir = join(projectRoot, "books");
+  const entries = await readdir(booksDir, { withFileTypes: true }).catch(() => []);
+  const bookIds = entries
+    .filter((entry) => entry.isDirectory())
+    .map((entry) => entry.name)
+    .sort();
+
+  if (session.activeBookId && bookIds.includes(session.activeBookId)) {
+    return session.activeBookId;
+  }
+
+  if (bookIds.length === 1) {
+    return bookIds[0];
+  }
+
+  return undefined;
+}

+ 34 - 0
packages/cli/src/tui/tools.ts

@@ -0,0 +1,34 @@
+import { writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { PipelineRunner, StateManager, type ReviseMode } from "@actalk/inkos-core";
+import type { InteractionRuntimeTools } from "@actalk/inkos-core";
+import { buildPipelineConfig, createClient, loadConfig } from "../utils.js";
+
+type PipelineLike = Pick<PipelineRunner, "writeNextChapter" | "reviseDraft">;
+type StateLike = Pick<StateManager, "ensureControlDocuments" | "bookDir">;
+
+export function createInteractionToolsFromDeps(
+  _projectRoot: string,
+  pipeline: PipelineLike,
+  state: StateLike,
+): InteractionRuntimeTools {
+  return {
+    writeNextChapter: (bookId) => pipeline.writeNextChapter(bookId),
+    reviseDraft: (bookId, chapterNumber, mode) => pipeline.reviseDraft(bookId, chapterNumber, mode as ReviseMode),
+    updateCurrentFocus: async (bookId, content) => {
+      await state.ensureControlDocuments(bookId);
+      await writeFile(join(state.bookDir(bookId), "story", "current_focus.md"), content, "utf-8");
+    },
+    updateAuthorIntent: async (bookId, content) => {
+      await state.ensureControlDocuments(bookId);
+      await writeFile(join(state.bookDir(bookId), "story", "author_intent.md"), content, "utf-8");
+    },
+  };
+}
+
+export async function createInteractionTools(projectRoot: string): Promise<InteractionRuntimeTools> {
+  const config = await loadConfig();
+  const pipeline = new PipelineRunner(buildPipelineConfig(config, projectRoot));
+  const state = new StateManager(projectRoot);
+  return createInteractionToolsFromDeps(projectRoot, pipeline, state);
+}