Explorar el Código

feat: add session transcript codec

fanghanjun hace 6 meses
padre
commit
0c02a3e1fa

+ 132 - 0
packages/core/src/__tests__/session-transcript.test.ts

@@ -0,0 +1,132 @@
+import { describe, expect, it, beforeEach, afterEach } from "vitest";
+import { mkdtemp, rm, readFile, mkdir, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { tmpdir } from "node:os";
+import {
+  appendTranscriptEvent,
+  nextTranscriptSeq,
+  readTranscriptEvents,
+  transcriptPath,
+} from "../interaction/session-transcript.js";
+import type {
+  MessageEvent,
+  RequestCommittedEvent,
+  RequestStartedEvent,
+} from "../interaction/session-transcript-schema.js";
+
+describe("session transcript codec", () => {
+  let projectRoot: string;
+
+  beforeEach(async () => {
+    projectRoot = await mkdtemp(join(tmpdir(), "inkos-transcript-"));
+  });
+
+  afterEach(async () => {
+    await rm(projectRoot, { recursive: true, force: true });
+  });
+
+  it("一行写入一个 JSON event 并保留 raw AgentMessage 字段", async () => {
+    const started: RequestStartedEvent = {
+      type: "request_started",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 1,
+      timestamp: 100,
+      input: "继续写",
+    };
+    const message: MessageEvent = {
+      type: "message",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      uuid: "m1",
+      parentUuid: null,
+      seq: 2,
+      role: "assistant",
+      timestamp: 101,
+      message: {
+        role: "assistant",
+        content: [
+          { type: "thinking", thinking: "推理", signature: "sig-1" },
+          { type: "text", text: "正文" },
+        ],
+        provider: "anthropic",
+        api: "anthropic-messages",
+        model: "claude",
+        usage: {
+          input: 1,
+          output: 2,
+          cacheRead: 0,
+          cacheWrite: 0,
+          totalTokens: 3,
+          cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
+        },
+        stopReason: "stop",
+        timestamp: 101,
+      },
+    };
+
+    await appendTranscriptEvent(projectRoot, started);
+    await appendTranscriptEvent(projectRoot, message);
+
+    const raw = await readFile(transcriptPath(projectRoot, "s1"), "utf-8");
+    expect(raw.trim().split("\n")).toHaveLength(2);
+
+    const events = await readTranscriptEvents(projectRoot, "s1");
+    expect(events).toHaveLength(2);
+    expect((events[1] as MessageEvent).message).toMatchObject({
+      role: "assistant",
+      content: [
+        { type: "thinking", thinking: "推理", signature: "sig-1" },
+        { type: "text", text: "正文" },
+      ],
+    });
+  });
+
+  it("跳过坏行并保留合法 event", async () => {
+    const dir = join(projectRoot, ".inkos", "sessions");
+    await mkdir(dir, { recursive: true });
+    await writeFile(
+      join(dir, "s1.jsonl"),
+      [
+        JSON.stringify({
+          type: "request_started",
+          version: 1,
+          sessionId: "s1",
+          requestId: "r1",
+          seq: 1,
+          timestamp: 1,
+          input: "hi",
+        }),
+        "{bad json",
+        JSON.stringify({
+          type: "request_committed",
+          version: 1,
+          sessionId: "s1",
+          requestId: "r1",
+          seq: 2,
+          timestamp: 2,
+        }),
+      ].join("\n"),
+    );
+
+    const events = await readTranscriptEvents(projectRoot, "s1");
+    expect(events.map((event) => event.type)).toEqual(["request_started", "request_committed"]);
+  });
+
+  it("按已有 transcript 分配单调递增 seq", async () => {
+    const committed: RequestCommittedEvent = {
+      type: "request_committed",
+      version: 1,
+      sessionId: "s1",
+      requestId: "r1",
+      seq: 7,
+      timestamp: 100,
+    };
+
+    await appendTranscriptEvent(projectRoot, committed);
+
+    await expect(nextTranscriptSeq(projectRoot, "s1")).resolves.toBe(8);
+  });
+});

+ 76 - 0
packages/core/src/interaction/session-transcript-schema.ts

@@ -0,0 +1,76 @@
+import { z } from "zod";
+
+export const TranscriptRoleSchema = z.enum(["user", "assistant", "toolResult", "system"]);
+export type TranscriptRole = z.infer<typeof TranscriptRoleSchema>;
+
+const BaseEventSchema = z.object({
+  version: z.literal(1),
+  sessionId: z.string().min(1),
+  seq: z.number().int().nonnegative(),
+  timestamp: z.number().int().nonnegative(),
+});
+
+export const SessionCreatedEventSchema = BaseEventSchema.extend({
+  type: z.literal("session_created"),
+  bookId: z.string().nullable(),
+  title: z.string().nullable().default(null),
+  createdAt: z.number().int().nonnegative(),
+  updatedAt: z.number().int().nonnegative(),
+});
+
+export const SessionMetadataUpdatedEventSchema = BaseEventSchema.extend({
+  type: z.literal("session_metadata_updated"),
+  bookId: z.string().nullable().optional(),
+  title: z.string().nullable().optional(),
+  updatedAt: z.number().int().nonnegative(),
+});
+
+export const RequestStartedEventSchema = BaseEventSchema.extend({
+  type: z.literal("request_started"),
+  requestId: z.string().min(1),
+  input: z.string(),
+});
+
+export const RequestCommittedEventSchema = BaseEventSchema.extend({
+  type: z.literal("request_committed"),
+  requestId: z.string().min(1),
+});
+
+export const RequestFailedEventSchema = BaseEventSchema.extend({
+  type: z.literal("request_failed"),
+  requestId: z.string().min(1),
+  error: z.string(),
+});
+
+export const MessageEventSchema = BaseEventSchema.extend({
+  type: z.literal("message"),
+  requestId: z.string().min(1),
+  uuid: z.string().min(1),
+  parentUuid: z.string().min(1).nullable(),
+  role: TranscriptRoleSchema,
+  piTurnIndex: z.number().int().nonnegative().optional(),
+  toolCallId: z.string().min(1).optional(),
+  sourceToolAssistantUuid: z.string().min(1).optional(),
+  legacyDisplay: z.object({
+    thinking: z.string().optional(),
+    toolExecutions: z.array(z.unknown()).optional(),
+  }).optional(),
+  message: z.unknown(),
+});
+
+export const TranscriptEventSchema = z.discriminatedUnion("type", [
+  SessionCreatedEventSchema,
+  SessionMetadataUpdatedEventSchema,
+  RequestStartedEventSchema,
+  RequestCommittedEventSchema,
+  RequestFailedEventSchema,
+  MessageEventSchema,
+]);
+
+export type SessionCreatedEvent = z.infer<typeof SessionCreatedEventSchema>;
+export type SessionMetadataUpdatedEvent = z.infer<typeof SessionMetadataUpdatedEventSchema>;
+export type RequestStartedEvent = z.infer<typeof RequestStartedEventSchema>;
+export type RequestCommittedEvent = z.infer<typeof RequestCommittedEventSchema>;
+export type RequestFailedEvent = z.infer<typeof RequestFailedEventSchema>;
+export type MessageEvent = z.infer<typeof MessageEventSchema>;
+export type TranscriptEvent = z.infer<typeof TranscriptEventSchema>;

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

@@ -0,0 +1,66 @@
+import { appendFile, mkdir, readFile } from "node:fs/promises";
+import { join } from "node:path";
+import { TranscriptEventSchema, type TranscriptEvent } from "./session-transcript-schema.js";
+
+const SESSIONS_DIR = ".inkos/sessions";
+const appendQueues = new Map<string, Promise<void>>();
+
+export function sessionsDir(projectRoot: string): string {
+  return join(projectRoot, SESSIONS_DIR);
+}
+
+export function transcriptPath(projectRoot: string, sessionId: string): string {
+  return join(sessionsDir(projectRoot), `${sessionId}.jsonl`);
+}
+
+export function legacyBookSessionPath(projectRoot: string, sessionId: string): string {
+  return join(sessionsDir(projectRoot), `${sessionId}.json`);
+}
+
+export async function readTranscriptEvents(
+  projectRoot: string,
+  sessionId: string,
+): Promise<TranscriptEvent[]> {
+  let raw: string;
+  try {
+    raw = await readFile(transcriptPath(projectRoot, sessionId), "utf-8");
+  } catch {
+    return [];
+  }
+
+  const events: TranscriptEvent[] = [];
+  for (const line of raw.split(/\r?\n/)) {
+    if (!line.trim()) continue;
+    try {
+      const parsed = TranscriptEventSchema.safeParse(JSON.parse(line));
+      if (parsed.success) events.push(parsed.data);
+    } catch {
+      continue;
+    }
+  }
+
+  return events.sort((a, b) => a.seq - b.seq);
+}
+
+export async function nextTranscriptSeq(projectRoot: string, sessionId: string): Promise<number> {
+  const events = await readTranscriptEvents(projectRoot, sessionId);
+  return events.reduce((max, event) => Math.max(max, event.seq), 0) + 1;
+}
+
+export async function appendTranscriptEvent(
+  projectRoot: string,
+  event: TranscriptEvent,
+): Promise<void> {
+  const key = `${projectRoot}:${event.sessionId}`;
+  const previous = appendQueues.get(key) ?? Promise.resolve();
+  const next = previous.then(async () => {
+    await mkdir(sessionsDir(projectRoot), { recursive: true });
+    await appendFile(
+      transcriptPath(projectRoot, event.sessionId),
+      `${JSON.stringify(event)}\n`,
+      "utf-8",
+    );
+  });
+  appendQueues.set(key, next.catch(() => undefined));
+  await next;
+}