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

feat: use jsonl for book sessions

fanghanjun 6 месяцев назад
Родитель
Сommit
f25b271a95

+ 35 - 0
packages/core/src/__tests__/book-session-store.test.ts

@@ -9,6 +9,7 @@ import {
   renameBookSession,
   renameBookSession,
   deleteBookSession,
   deleteBookSession,
   migrateBookSession,
   migrateBookSession,
+  createAndPersistBookSession,
   extractFirstUserMessageTitle,
   extractFirstUserMessageTitle,
   SessionAlreadyMigratedError,
   SessionAlreadyMigratedError,
 } from "../interaction/book-session-store.js";
 } from "../interaction/book-session-store.js";
@@ -107,6 +108,40 @@ describe("book-session-store", () => {
         .resolves
         .resolves
         .toContain("request_committed");
         .toContain("request_committed");
     });
     });
+
+    it("createAndPersistBookSession 为新 session 写 JSONL 而不是 legacy JSON", async () => {
+      const session = await createAndPersistBookSession(tempDir, "book-a", "123456-abcdef");
+
+      expect(session.sessionId).toBe("123456-abcdef");
+      await expect(readFile(join(tempDir, ".inkos", "sessions", "123456-abcdef.jsonl"), "utf-8"))
+        .resolves
+        .toContain("session_created");
+      await expect(readFile(join(tempDir, ".inkos", "sessions", "123456-abcdef.json"), "utf-8"))
+        .rejects
+        .toThrow();
+    });
+
+    it("renameBookSession 追加 metadata event 并从 JSONL 派生新标题", async () => {
+      await createAndPersistBookSession(tempDir, "book-a", "123456-abcdef");
+
+      const renamed = await renameBookSession(tempDir, "123456-abcdef", "新标题");
+
+      expect(renamed!.title).toBe("新标题");
+      const loaded = await loadBookSession(tempDir, "123456-abcdef");
+      expect(loaded!.title).toBe("新标题");
+    });
+
+    it("listBookSessions 同时列出 JSONL session 和未迁移 legacy JSON session", async () => {
+      await createAndPersistBookSession(tempDir, "book-a", "123456-abcdef");
+      const dir = join(tempDir, ".inkos", "sessions");
+      await mkdir(dir, { recursive: true });
+      const legacy = { ...createBookSession("book-a", "legacy-1"), updatedAt: 999 };
+      await writeFile(join(dir, "legacy-1.json"), JSON.stringify(legacy));
+
+      const list = await listBookSessions(tempDir, "book-a");
+
+      expect(list.map((entry) => entry.sessionId).sort()).toEqual(["123456-abcdef", "legacy-1"]);
+    });
   });
   });
 
 
   describe("listBookSessions", () => {
   describe("listBookSessions", () => {

+ 105 - 84
packages/core/src/interaction/book-session-store.ts

@@ -1,18 +1,19 @@
-import { readFile, writeFile, readdir, mkdir, unlink } from "node:fs/promises";
-import { join } from "node:path";
-import { BookSessionSchema, createBookSession } from "./session.js";
+import { readdir, unlink } from "node:fs/promises";
+import { createBookSession } from "./session.js";
 import type { BookSession } from "./session.js";
 import type { BookSession } from "./session.js";
-import { migrateLegacyBookSessionToTranscript } from "./session-transcript-legacy.js";
-
-const SESSIONS_DIR = ".inkos/sessions";
-
-function sessionsDir(projectRoot: string): string {
-  return join(projectRoot, SESSIONS_DIR);
-}
-
-function sessionPath(projectRoot: string, sessionId: string): string {
-  return join(sessionsDir(projectRoot), `${sessionId}.json`);
-}
+import {
+  appendTranscriptEvent,
+  legacyBookSessionPath,
+  nextTranscriptSeq,
+  readTranscriptEvents,
+  sessionsDir,
+  transcriptPath,
+} from "./session-transcript.js";
+import {
+  migrateLegacyBookSessionToTranscript,
+  readLegacyBookSession,
+} from "./session-transcript-legacy.js";
+import { deriveBookSessionFromTranscript } from "./session-transcript-restore.js";
 
 
 /**
 /**
  * 从 messages 数组里取第一条 user 消息,裁剪成 ≤20 字的单行字符串。
  * 从 messages 数组里取第一条 user 消息,裁剪成 ≤20 字的单行字符串。
@@ -43,26 +44,73 @@ export async function loadBookSession(
   projectRoot: string,
   projectRoot: string,
   sessionId: string,
   sessionId: string,
 ): Promise<BookSession | null> {
 ): Promise<BookSession | null> {
-  try {
-    const raw = await readFile(sessionPath(projectRoot, sessionId), "utf-8");
-    const session = BookSessionSchema.parse(JSON.parse(raw));
-    await migrateLegacyBookSessionToTranscript(projectRoot, session);
-    return session;
-  } catch {
-    return null;
-  }
+  const transcriptSession = await deriveBookSessionFromTranscript(projectRoot, sessionId);
+  if (transcriptSession) return transcriptSession;
+
+  const legacySession = await readLegacyBookSession(projectRoot, sessionId);
+  if (!legacySession) return null;
+
+  await migrateLegacyBookSessionToTranscript(projectRoot, legacySession);
+  return await deriveBookSessionFromTranscript(projectRoot, sessionId) ?? legacySession;
+}
+
+async function appendSessionCreatedEvent(
+  projectRoot: string,
+  session: BookSession,
+): Promise<void> {
+  await appendTranscriptEvent(projectRoot, {
+    type: "session_created",
+    version: 1,
+    sessionId: session.sessionId,
+    seq: await nextTranscriptSeq(projectRoot, session.sessionId),
+    timestamp: session.createdAt,
+    bookId: session.bookId,
+    title: session.title,
+    createdAt: session.createdAt,
+    updatedAt: session.updatedAt,
+  });
+}
+
+async function appendSessionMetadataUpdatedEvent(
+  projectRoot: string,
+  sessionId: string,
+  metadata: {
+    readonly bookId?: string | null;
+    readonly title?: string | null;
+    readonly updatedAt: number;
+  },
+): Promise<void> {
+  await appendTranscriptEvent(projectRoot, {
+    type: "session_metadata_updated",
+    version: 1,
+    sessionId,
+    seq: await nextTranscriptSeq(projectRoot, sessionId),
+    timestamp: metadata.updatedAt,
+    updatedAt: metadata.updatedAt,
+    ...("bookId" in metadata ? { bookId: metadata.bookId } : {}),
+    ...("title" in metadata ? { title: metadata.title } : {}),
+  });
 }
 }
 
 
 export async function persistBookSession(
 export async function persistBookSession(
   projectRoot: string,
   projectRoot: string,
   session: BookSession,
   session: BookSession,
 ): Promise<void> {
 ): Promise<void> {
-  const dir = sessionsDir(projectRoot);
-  await mkdir(dir, { recursive: true });
-  await writeFile(
-    sessionPath(projectRoot, session.sessionId),
-    JSON.stringify(session, null, 2),
-  );
+  const events = await readTranscriptEvents(projectRoot, session.sessionId);
+  if (events.length === 0) {
+    if (session.messages.length === 0) {
+      await appendSessionCreatedEvent(projectRoot, session);
+      return;
+    }
+    await migrateLegacyBookSessionToTranscript(projectRoot, session);
+    return;
+  }
+
+  await appendSessionMetadataUpdatedEvent(projectRoot, session.sessionId, {
+    bookId: session.bookId,
+    title: session.title,
+    updatedAt: session.updatedAt,
+  });
 }
 }
 
 
 export interface BookSessionSummary {
 export interface BookSessionSummary {
@@ -86,52 +134,28 @@ export async function listBookSessions(
     return [];
     return [];
   }
   }
 
 
-  const jsonFiles = files.filter((file) => file.endsWith(".json"));
+  const sessionIds = new Set<string>();
+  for (const file of files) {
+    if (file.endsWith(".jsonl")) {
+      sessionIds.add(file.slice(0, -".jsonl".length));
+    } else if (file.endsWith(".json")) {
+      sessionIds.add(file.slice(0, -".json".length));
+    }
+  }
+
   const summaries = await Promise.all(
   const summaries = await Promise.all(
-    jsonFiles.map(async (file): Promise<BookSessionSummary | null> => {
+    [...sessionIds].map(async (sessionId): Promise<BookSessionSummary | null> => {
       try {
       try {
-        const raw = await readFile(join(dir, file), "utf-8");
-        const data = JSON.parse(raw) as {
-          sessionId?: unknown;
-          bookId?: unknown;
-          title?: unknown;
-          messages?: unknown;
-          createdAt?: unknown;
-          updatedAt?: unknown;
-        };
-        if (typeof data.sessionId !== "string") return null;
-        const parsedBookId = data.bookId === null || typeof data.bookId === "string"
-          ? (data.bookId as string | null)
-          : null;
-        if (parsedBookId !== bookId) return null;
-
-        let persistedTitle = typeof data.title === "string" ? data.title : null;
-
-        // Lazy migration:老 session 的 title 字段是 null 但已经有用户消息的,
-        // 一次性把第一条用户消息补写成 title 并 persist 回磁盘。用户在新流程中
-        // 发消息时会立即写 title,此 migration 只对历史数据生效一次。
-        if (persistedTitle === null) {
-          const recoveredTitle = extractFirstUserMessageTitle(data.messages);
-          if (recoveredTitle) {
-            try {
-              const fullSession = await loadBookSession(projectRoot, data.sessionId);
-              if (fullSession && fullSession.title === null) {
-                await persistBookSession(projectRoot, { ...fullSession, title: recoveredTitle });
-                persistedTitle = recoveredTitle;
-              }
-            } catch {
-              // 读不出完整 session 就忽略;下次再试
-            }
-          }
-        }
+        const session = await loadBookSession(projectRoot, sessionId);
+        if (!session || session.bookId !== bookId) return null;
 
 
         return {
         return {
-          sessionId: data.sessionId,
-          bookId: parsedBookId,
-          title: persistedTitle,
-          messageCount: Array.isArray(data.messages) ? data.messages.length : 0,
-          createdAt: typeof data.createdAt === "number" ? data.createdAt : 0,
-          updatedAt: typeof data.updatedAt === "number" ? data.updatedAt : 0,
+          sessionId: session.sessionId,
+          bookId: session.bookId,
+          title: session.title,
+          messageCount: session.messages.length,
+          createdAt: session.createdAt,
+          updatedAt: session.updatedAt,
         };
         };
       } catch {
       } catch {
         return null;
         return null;
@@ -151,20 +175,19 @@ export async function renameBookSession(
 ): Promise<BookSession | null> {
 ): Promise<BookSession | null> {
   const session = await loadBookSession(projectRoot, sessionId);
   const session = await loadBookSession(projectRoot, sessionId);
   if (!session) return null;
   if (!session) return null;
-  const updated = { ...session, title, updatedAt: Date.now() };
-  await persistBookSession(projectRoot, updated);
-  return updated;
+  const updatedAt = Date.now();
+  await appendSessionMetadataUpdatedEvent(projectRoot, sessionId, { title, updatedAt });
+  return loadBookSession(projectRoot, sessionId);
 }
 }
 
 
 export async function deleteBookSession(
 export async function deleteBookSession(
   projectRoot: string,
   projectRoot: string,
   sessionId: string,
   sessionId: string,
 ): Promise<void> {
 ): Promise<void> {
-  try {
-    await unlink(sessionPath(projectRoot, sessionId));
-  } catch {
-    // Session file is already absent; treat delete as idempotent.
-  }
+  await Promise.all([
+    unlink(transcriptPath(projectRoot, sessionId)).catch(() => undefined),
+    unlink(legacyBookSessionPath(projectRoot, sessionId)).catch(() => undefined),
+  ]);
 }
 }
 
 
 export async function migrateBookSession(
 export async function migrateBookSession(
@@ -178,13 +201,11 @@ export async function migrateBookSession(
     throw new SessionAlreadyMigratedError(sessionId, session.bookId);
     throw new SessionAlreadyMigratedError(sessionId, session.bookId);
   }
   }
 
 
-  const updated = {
-    ...session,
+  await appendSessionMetadataUpdatedEvent(projectRoot, sessionId, {
     bookId: newBookId,
     bookId: newBookId,
     updatedAt: Date.now(),
     updatedAt: Date.now(),
-  };
-  await persistBookSession(projectRoot, updated);
-  return updated;
+  });
+  return loadBookSession(projectRoot, sessionId);
 }
 }
 
 
 export async function createAndPersistBookSession(
 export async function createAndPersistBookSession(
@@ -198,6 +219,6 @@ export async function createAndPersistBookSession(
     if (existing) return existing;
     if (existing) return existing;
   }
   }
   const session = createBookSession(bookId, sessionId);
   const session = createBookSession(bookId, sessionId);
-  await persistBookSession(projectRoot, session);
+  await appendSessionCreatedEvent(projectRoot, session);
   return session;
   return session;
 }
 }