Преглед на файлове

feat: derive sessions from transcripts

fanghanjun преди 6 месеца
родител
ревизия
2d1ba27f46

+ 26 - 1
packages/core/src/__tests__/book-session-store.test.ts

@@ -13,7 +13,7 @@ import {
   SessionAlreadyMigratedError,
   SessionAlreadyMigratedError,
 } from "../interaction/book-session-store.js";
 } from "../interaction/book-session-store.js";
 import { createBookSession, appendBookSessionMessage } from "../interaction/session.js";
 import { createBookSession, appendBookSessionMessage } from "../interaction/session.js";
-import { mkdir, writeFile } from "node:fs/promises";
+import { mkdir, readFile, writeFile } from "node:fs/promises";
 
 
 describe("book-session-store", () => {
 describe("book-session-store", () => {
   let tempDir: string;
   let tempDir: string;
@@ -82,6 +82,31 @@ describe("book-session-store", () => {
       const loaded = await loadBookSession(tempDir, session.sessionId);
       const loaded = await loadBookSession(tempDir, session.sessionId);
       expect(loaded!.title).toBe("测试标题");
       expect(loaded!.title).toBe("测试标题");
     });
     });
+
+    it("读取 legacy JSON 时迁移为 JSONL 并保留 UI thinking", async () => {
+      const session = {
+        ...createBookSession("book-a", "legacy-1"),
+        messages: [
+          { role: "user" as const, content: "继续", timestamp: 10 },
+          { role: "assistant" as const, content: "好的", thinking: "旧思考", timestamp: 11 },
+        ],
+      };
+      await persistBookSession(tempDir, session);
+
+      const loaded = await loadBookSession(tempDir, "legacy-1");
+
+      expect(loaded).toMatchObject({
+        sessionId: "legacy-1",
+        bookId: "book-a",
+        messages: [
+          { role: "user", content: "继续" },
+          { role: "assistant", content: "好的", thinking: "旧思考" },
+        ],
+      });
+      await expect(readFile(join(tempDir, ".inkos", "sessions", "legacy-1.jsonl"), "utf-8"))
+        .resolves
+        .toContain("request_committed");
+    });
   });
   });
 
 
   describe("listBookSessions", () => {
   describe("listBookSessions", () => {

+ 83 - 1
packages/core/src/__tests__/session-transcript-restore.test.ts

@@ -3,7 +3,10 @@ import { mkdtemp, rm } from "node:fs/promises";
 import { join } from "node:path";
 import { join } from "node:path";
 import { tmpdir } from "node:os";
 import { tmpdir } from "node:os";
 import { appendTranscriptEvent } from "../interaction/session-transcript.js";
 import { appendTranscriptEvent } from "../interaction/session-transcript.js";
-import { restoreAgentMessagesFromTranscript } from "../interaction/session-transcript-restore.js";
+import {
+  deriveBookSessionFromTranscript,
+  restoreAgentMessagesFromTranscript,
+} from "../interaction/session-transcript-restore.js";
 import type { MessageEvent } from "../interaction/session-transcript-schema.js";
 import type { MessageEvent } from "../interaction/session-transcript-schema.js";
 
 
 const usage = {
 const usage = {
@@ -210,4 +213,83 @@ describe("session transcript restore", () => {
 
 
     expect((restored[0] as any).content).toEqual([{ type: "text", text: "回答" }]);
     expect((restored[0] as any).content).toEqual([{ type: "text", text: "回答" }]);
   });
   });
+
+  it("从 transcript 派生 BookSession UI 视图", async () => {
+    await appendTranscriptEvent(projectRoot, {
+      type: "session_created",
+      version: 1,
+      sessionId: "s1",
+      seq: 1,
+      timestamp: 1,
+      bookId: "book-a",
+      title: null,
+      createdAt: 1,
+      updatedAt: 1,
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_started",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 2,
+      timestamp: 2,
+      input: "第一条问题",
+    });
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "u1",
+      parentUuid: null,
+      seq: 3,
+      role: "user",
+      timestamp: 3,
+      message: { role: "user", content: "第一条问题", timestamp: 3 },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "a1",
+      parentUuid: "u1",
+      seq: 4,
+      role: "assistant",
+      timestamp: 4,
+      message: {
+        role: "assistant",
+        content: [
+          { type: "thinking", thinking: "思考" },
+          { type: "text", text: "回答" },
+        ],
+        api: "anthropic-messages",
+        provider: "anthropic",
+        model: "claude",
+        usage,
+        stopReason: "stop",
+        timestamp: 4,
+      },
+    } as MessageEvent);
+    await appendTranscriptEvent(projectRoot, {
+      type: "request_committed",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 5,
+      timestamp: 5,
+    });
+
+    const session = await deriveBookSessionFromTranscript(projectRoot, "s1");
+
+    expect(session).toMatchObject({
+      sessionId: "s1",
+      bookId: "book-a",
+      title: "第一条问题",
+      messages: [
+        { role: "user", content: "第一条问题" },
+        { role: "assistant", content: "回答", thinking: "思考" },
+      ],
+    });
+  });
 });
 });

+ 4 - 1
packages/core/src/interaction/book-session-store.ts

@@ -2,6 +2,7 @@ import { readFile, writeFile, readdir, mkdir, unlink } from "node:fs/promises";
 import { join } from "node:path";
 import { join } from "node:path";
 import { BookSessionSchema, createBookSession } from "./session.js";
 import { BookSessionSchema, 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";
 const SESSIONS_DIR = ".inkos/sessions";
 
 
@@ -44,7 +45,9 @@ export async function loadBookSession(
 ): Promise<BookSession | null> {
 ): Promise<BookSession | null> {
   try {
   try {
     const raw = await readFile(sessionPath(projectRoot, sessionId), "utf-8");
     const raw = await readFile(sessionPath(projectRoot, sessionId), "utf-8");
-    return BookSessionSchema.parse(JSON.parse(raw));
+    const session = BookSessionSchema.parse(JSON.parse(raw));
+    await migrateLegacyBookSessionToTranscript(projectRoot, session);
+    return session;
   } catch {
   } catch {
     return null;
     return null;
   }
   }

+ 110 - 0
packages/core/src/interaction/session-transcript-legacy.ts

@@ -0,0 +1,110 @@
+import { randomUUID } from "node:crypto";
+import { readFile } from "node:fs/promises";
+import { BookSessionSchema, type BookSession } from "./session.js";
+import {
+  appendTranscriptEvent,
+  legacyBookSessionPath,
+  nextTranscriptSeq,
+  readTranscriptEvents,
+} from "./session-transcript.js";
+import type { MessageEvent } from "./session-transcript-schema.js";
+
+const EMPTY_USAGE = {
+  input: 0,
+  output: 0,
+  cacheRead: 0,
+  cacheWrite: 0,
+  totalTokens: 0,
+  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+};
+
+export async function readLegacyBookSession(
+  projectRoot: string,
+  sessionId: string,
+): Promise<BookSession | null> {
+  try {
+    const raw = await readFile(legacyBookSessionPath(projectRoot, sessionId), "utf-8");
+    return BookSessionSchema.parse(JSON.parse(raw));
+  } catch {
+    return null;
+  }
+}
+
+export async function migrateLegacyBookSessionToTranscript(
+  projectRoot: string,
+  session: BookSession,
+): Promise<void> {
+  const existing = await readTranscriptEvents(projectRoot, session.sessionId);
+  if (existing.length > 0) return;
+
+  let seq = await nextTranscriptSeq(projectRoot, session.sessionId);
+  await appendTranscriptEvent(projectRoot, {
+    type: "session_created",
+    version: 1,
+    sessionId: session.sessionId,
+    seq: seq++,
+    timestamp: session.createdAt,
+    bookId: session.bookId,
+    title: session.title,
+    createdAt: session.createdAt,
+    updatedAt: session.updatedAt,
+  });
+
+  const requestId = `legacy-${session.sessionId}`;
+  await appendTranscriptEvent(projectRoot, {
+    type: "request_started",
+    version: 1,
+    sessionId: session.sessionId,
+    requestId,
+    seq: seq++,
+    timestamp: session.createdAt,
+    input: "",
+  });
+
+  let parentUuid: string | null = null;
+  for (const legacyMessage of session.messages) {
+    const uuid = randomUUID();
+    const message = legacyMessage.role === "assistant"
+      ? {
+          role: "assistant",
+          content: [{ type: "text", text: legacyMessage.content }],
+          api: "anthropic-messages",
+          provider: "legacy",
+          model: "unknown",
+          usage: EMPTY_USAGE,
+          stopReason: "stop",
+          timestamp: legacyMessage.timestamp,
+        }
+      : {
+          role: legacyMessage.role,
+          content: legacyMessage.content,
+          timestamp: legacyMessage.timestamp,
+        };
+    const event: MessageEvent = {
+      type: "message",
+      version: 1,
+      sessionId: session.sessionId,
+      requestId,
+      uuid,
+      parentUuid,
+      seq: seq++,
+      role: legacyMessage.role === "assistant" ? "assistant" : legacyMessage.role,
+      timestamp: legacyMessage.timestamp,
+      ...(legacyMessage.role === "assistant" && legacyMessage.thinking
+        ? { legacyDisplay: { thinking: legacyMessage.thinking } }
+        : {}),
+      message,
+    };
+    await appendTranscriptEvent(projectRoot, event);
+    parentUuid = uuid;
+  }
+
+  await appendTranscriptEvent(projectRoot, {
+    type: "request_committed",
+    version: 1,
+    sessionId: session.sessionId,
+    requestId,
+    seq: seq++,
+    timestamp: session.updatedAt,
+  });
+}

+ 115 - 0
packages/core/src/interaction/session-transcript-restore.ts

@@ -1,5 +1,11 @@
 import type { AgentMessage } from "@mariozechner/pi-agent-core";
 import type { AgentMessage } from "@mariozechner/pi-agent-core";
 import { readTranscriptEvents } from "./session-transcript.js";
 import { readTranscriptEvents } from "./session-transcript.js";
+import {
+  BookSessionSchema,
+  type BookSession,
+  type InteractionMessage,
+  type ToolExecution,
+} from "./session.js";
 import type { MessageEvent, TranscriptEvent } from "./session-transcript-schema.js";
 import type { MessageEvent, TranscriptEvent } from "./session-transcript-schema.js";
 
 
 function isObject(value: unknown): value is Record<string, unknown> {
 function isObject(value: unknown): value is Record<string, unknown> {
@@ -102,3 +108,112 @@ export async function restoreAgentMessagesFromTranscript(
     committedMessageEvents(events).map((event) => event.message as AgentMessage),
     committedMessageEvents(events).map((event) => event.message as AgentMessage),
   );
   );
 }
 }
+
+function textFromContent(content: unknown): string {
+  if (typeof content === "string") return content;
+  if (!Array.isArray(content)) return "";
+  return content
+    .filter(
+      (block): block is { type: string; text: string } =>
+        isObject(block) && block.type === "text" && typeof block.text === "string",
+    )
+    .map((block) => block.text)
+    .join("");
+}
+
+function thinkingFromContent(content: unknown): string | undefined {
+  if (!Array.isArray(content)) return undefined;
+  const value = content
+    .filter((block): block is Record<string, unknown> => isObject(block) && block.type === "thinking")
+    .map((block) => typeof block.thinking === "string" ? block.thinking : "")
+    .join("");
+  return value || undefined;
+}
+
+function firstUserMessageTitle(messages: InteractionMessage[]): string | null {
+  for (const message of messages) {
+    if (message.role !== "user") continue;
+    const oneLine = message.content.trim().replace(/\s+/g, " ");
+    if (!oneLine) return null;
+    return oneLine.length > 20 ? `${oneLine.slice(0, 20)}…` : oneLine;
+  }
+  return null;
+}
+
+function messageEventToInteractionMessage(event: MessageEvent): InteractionMessage | null {
+  const raw = event.message as Record<string, unknown>;
+  if (!isObject(raw)) return null;
+  if (event.role === "toolResult") return null;
+
+  if (event.role === "user") {
+    const content = textFromContent(raw.content);
+    return content ? { role: "user", content, timestamp: event.timestamp } : null;
+  }
+
+  if (event.role === "assistant") {
+    const content = textFromContent(raw.content);
+    const thinking = thinkingFromContent(raw.content) ?? event.legacyDisplay?.thinking;
+    if (!content && !thinking) return null;
+    return {
+      role: "assistant",
+      content,
+      ...(thinking ? { thinking } : {}),
+      ...(event.legacyDisplay?.toolExecutions
+        ? { toolExecutions: event.legacyDisplay.toolExecutions as ToolExecution[] }
+        : {}),
+      timestamp: event.timestamp,
+    };
+  }
+
+  if (event.role === "system") {
+    const content = textFromContent(raw.content);
+    return content ? { role: "system", content, timestamp: event.timestamp } : null;
+  }
+
+  return null;
+}
+
+export async function deriveBookSessionFromTranscript(
+  projectRoot: string,
+  sessionId: string,
+): Promise<BookSession | null> {
+  const events = await readTranscriptEvents(projectRoot, sessionId);
+  if (events.length === 0) return null;
+
+  const created = events.find((event) => event.type === "session_created");
+  let bookId = created?.type === "session_created" ? created.bookId : null;
+  let title = created?.type === "session_created" ? created.title : null;
+  const createdAt = created?.type === "session_created"
+    ? created.createdAt
+    : events[0]?.timestamp ?? Date.now();
+  let updatedAt = created?.type === "session_created"
+    ? created.updatedAt
+    : events[events.length - 1]?.timestamp ?? createdAt;
+
+  for (const event of events) {
+    if (event.type !== "session_metadata_updated") continue;
+    if ("bookId" in event && event.bookId !== undefined) bookId = event.bookId;
+    if ("title" in event && event.title !== undefined) title = event.title;
+    updatedAt = event.updatedAt;
+  }
+
+  const messages = committedMessageEvents(events)
+    .map(messageEventToInteractionMessage)
+    .filter((message): message is InteractionMessage => message !== null)
+    .sort((a, b) => a.timestamp - b.timestamp);
+
+  if (title === null) {
+    title = firstUserMessageTitle(messages);
+  }
+
+  return BookSessionSchema.parse({
+    sessionId,
+    bookId,
+    title,
+    messages,
+    draftRounds: [],
+    events: [],
+    createdAt,
+    updatedAt,
+  });
+}