diff --git a/src/lib/conversation-schema/content/ImageContentSchema.ts b/src/lib/conversation-schema/content/ImageContentSchema.ts new file mode 100644 index 0000000..a08df21 --- /dev/null +++ b/src/lib/conversation-schema/content/ImageContentSchema.ts @@ -0,0 +1,10 @@ +import { z } from 'zod' + +export const ImageContentSchema = z.object({ + type: z.literal('image'), + source: z.object({ + type: z.literal('base64'), + data: z.string(), + media_type: z.enum(['image/png']), + }), +}).strict() diff --git a/src/lib/conversation-schema/content/TextContentSchema.ts b/src/lib/conversation-schema/content/TextContentSchema.ts new file mode 100644 index 0000000..46eb68b --- /dev/null +++ b/src/lib/conversation-schema/content/TextContentSchema.ts @@ -0,0 +1,6 @@ +import { z } from 'zod' + +export const TextContentSchema = z.object({ + type: z.literal('text'), + text: z.string(), +}).strict() diff --git a/src/lib/conversation-schema/content/ThinkingContentSchema.ts b/src/lib/conversation-schema/content/ThinkingContentSchema.ts new file mode 100644 index 0000000..b77bb1d --- /dev/null +++ b/src/lib/conversation-schema/content/ThinkingContentSchema.ts @@ -0,0 +1,7 @@ +import { z } from 'zod' + +export const ThinkingContentSchema = z.object({ + type: z.literal('thinking'), + thinking: z.string(), + signature: z.string().optional(), +}).strict() diff --git a/src/lib/conversation-schema/content/ToolResultContentSchema.ts b/src/lib/conversation-schema/content/ToolResultContentSchema.ts new file mode 100644 index 0000000..0fe2a49 --- /dev/null +++ b/src/lib/conversation-schema/content/ToolResultContentSchema.ts @@ -0,0 +1,16 @@ +import { z } from 'zod' +import { TextContentSchema } from './TextContentSchema' +import { ImageContentSchema } from './ImageContentSchema' + +export const ToolResultContentSchema = z.object({ + type: z.literal('tool_result'), + tool_use_id: z.string(), + content: z.union([z.string(), z.array(z.union([ + TextContentSchema, + ImageContentSchema + ]))]), + is_error: z.boolean().optional(), +}).strict() + + +export type ToolResultContent = z.infer diff --git a/src/lib/conversation-schema/content/ToolUseContentSchema.ts b/src/lib/conversation-schema/content/ToolUseContentSchema.ts new file mode 100644 index 0000000..799285c --- /dev/null +++ b/src/lib/conversation-schema/content/ToolUseContentSchema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod' + +export const ToolUseContentSchema = z.object({ + type: z.literal('tool_use'), + id: z.string(), + name: z.string(), + input: z.record(z.string(), z.unknown()), +}).strict() diff --git a/src/lib/conversation-schema/entry/AssistantEntrySchema.ts b/src/lib/conversation-schema/entry/AssistantEntrySchema.ts new file mode 100644 index 0000000..d4130fc --- /dev/null +++ b/src/lib/conversation-schema/entry/AssistantEntrySchema.ts @@ -0,0 +1,15 @@ +import { z } from 'zod' +import { AssistantMessageSchema } from '../message/AssistantMessageSchema' +import { BaseEntrySchema } from './BaseEntrySchema' + +export const AssistantEntrySchema = BaseEntrySchema.extend({ + // discriminator + type: z.literal('assistant'), + + // required + message: AssistantMessageSchema, + + // optional + requestId: z.string().optional(), + isApiErrorMessage: z.boolean().optional(), +}).strict() diff --git a/src/lib/conversation-schema/entry/BaseEntrySchema.ts b/src/lib/conversation-schema/entry/BaseEntrySchema.ts new file mode 100644 index 0000000..12d1498 --- /dev/null +++ b/src/lib/conversation-schema/entry/BaseEntrySchema.ts @@ -0,0 +1,21 @@ +import { z } from 'zod' + +export const BaseEntrySchema = z.object({ + // required + isSidechain: z.boolean(), + userType: z.enum(['external']), + cwd: z.string(), + sessionId: z.string(), + version: z.string(), + uuid: z.uuid(), + timestamp: z.string(), + + // nullable + parentUuid: z.uuid().nullable(), + + // optional + isMeta: z.boolean().optional(), + toolUseResult: z.unknown().optional(), // スキーマがツールごとに異なりすぎるし利用もしなそうなので unknown + gitBranch: z.string().optional(), + isCompactSummary: z.boolean().optional(), +}).strict() diff --git a/src/lib/conversation-schema/entry/SummaryEntrySchema.ts b/src/lib/conversation-schema/entry/SummaryEntrySchema.ts new file mode 100644 index 0000000..7c798d1 --- /dev/null +++ b/src/lib/conversation-schema/entry/SummaryEntrySchema.ts @@ -0,0 +1,7 @@ +import { z } from 'zod' + +export const SummaryEntrySchema = z.object({ + type: z.literal('summary'), + summary: z.string(), + leafUuid: z.string().uuid(), +}).strict() diff --git a/src/lib/conversation-schema/entry/SystemEntrySchema.ts b/src/lib/conversation-schema/entry/SystemEntrySchema.ts new file mode 100644 index 0000000..a232731 --- /dev/null +++ b/src/lib/conversation-schema/entry/SystemEntrySchema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod' +import { BaseEntrySchema } from './BaseEntrySchema' + +export const SystemEntrySchema = BaseEntrySchema.extend({ + // discriminator + type: z.literal('system'), + + // required + content: z.string(), + toolUseID: z.string(), + level: z.enum(['info']), +}).strict() diff --git a/src/lib/conversation-schema/entry/UserEntrySchema.ts b/src/lib/conversation-schema/entry/UserEntrySchema.ts new file mode 100644 index 0000000..56f1f13 --- /dev/null +++ b/src/lib/conversation-schema/entry/UserEntrySchema.ts @@ -0,0 +1,11 @@ +import { z } from 'zod' +import { UserMessageSchema } from '../message/UserMessageSchema' +import { BaseEntrySchema } from './BaseEntrySchema' + +export const UserEntrySchema = BaseEntrySchema.extend({ + // discriminator + type: z.literal('user'), + + // required + message: UserMessageSchema, +}).strict() diff --git a/src/lib/conversation-schema/index.ts b/src/lib/conversation-schema/index.ts new file mode 100644 index 0000000..1f533b9 --- /dev/null +++ b/src/lib/conversation-schema/index.ts @@ -0,0 +1,14 @@ +import { z } from 'zod' +import { UserEntrySchema } from './entry/UserEntrySchema' +import { AssistantEntrySchema } from './entry/AssistantEntrySchema' +import { SummaryEntrySchema } from './entry/SummaryEntrySchema' +import { SystemEntrySchema } from './entry/SystemEntrySchema' + +export const ConversationSchema = z.union([ + UserEntrySchema, + AssistantEntrySchema, + SummaryEntrySchema, + SystemEntrySchema, +]) + +export type Conversation = z.infer diff --git a/src/lib/conversation-schema/message/AssistantMessageSchema.ts b/src/lib/conversation-schema/message/AssistantMessageSchema.ts new file mode 100644 index 0000000..de16f6d --- /dev/null +++ b/src/lib/conversation-schema/message/AssistantMessageSchema.ts @@ -0,0 +1,38 @@ +import { z } from 'zod' +import { ThinkingContentSchema } from '../content/ThinkingContentSchema' +import { TextContentSchema } from '../content/TextContentSchema' +import { ToolUseContentSchema } from '../content/ToolUseContentSchema' +import { ToolResultContentSchema } from '../content/ToolResultContentSchema' + +const AssistantMessageContentSchema = z.union([ + ThinkingContentSchema, + TextContentSchema, + ToolUseContentSchema, + ToolResultContentSchema, +]) + +export type AssistantMessageContent = z.infer + +export const AssistantMessageSchema = z.object({ + id: z.string(), + type: z.literal('message'), + role: z.literal('assistant'), + model: z.string(), + content: z.array(AssistantMessageContentSchema), + stop_reason: z.string().nullable(), + stop_sequence: z.string().nullable(), + usage: z.object({ + input_tokens: z.number(), + cache_creation_input_tokens: z.number().optional(), + cache_read_input_tokens: z.number().optional(), + cache_creation: z.object({ + ephemeral_5m_input_tokens: z.number(), + ephemeral_1h_input_tokens: z.number(), + }).optional(), + output_tokens: z.number(), + service_tier: z.string().nullable().optional(), + server_tool_use: z.object({ + web_search_requests: z.number(), + }).optional(), + }), +}).strict() diff --git a/src/lib/conversation-schema/message/UserMessageSchema.ts b/src/lib/conversation-schema/message/UserMessageSchema.ts new file mode 100644 index 0000000..675e496 --- /dev/null +++ b/src/lib/conversation-schema/message/UserMessageSchema.ts @@ -0,0 +1,25 @@ +import { z } from 'zod' +import { ToolResultContentSchema } from '../content/ToolResultContentSchema' +import { TextContentSchema } from '../content/TextContentSchema' +import { ImageContentSchema } from '../content/ImageContentSchema' + + +const UserMessageContentSchema = z.union([ + z.string(), + TextContentSchema, + ToolResultContentSchema, + ImageContentSchema +]) + +export type UserMessageContent = z.infer + +export const UserMessageSchema = z.object({ + role: z.literal('user'), + content: z.union([ + z.string(), + z.array(z.union([ + z.string(), + UserMessageContentSchema + ])) + ]), +}).strict() diff --git a/src/lib/conversation-schema/tool/CommonToolSchema.ts b/src/lib/conversation-schema/tool/CommonToolSchema.ts new file mode 100644 index 0000000..132982f --- /dev/null +++ b/src/lib/conversation-schema/tool/CommonToolSchema.ts @@ -0,0 +1,59 @@ +import { z } from 'zod' +import { StructuredPatchSchema } from './StructuredPatchSchema' + +export const CommonToolResultSchema = z.union([ + z.object({ + stdout: z.string(), + stderr: z.string(), + interrupted: z.boolean(), + isImage: z.boolean(), + }).strict(), + + // create + z.object({ + type: z.literal('create'), + filePath: z.string(), + content: z.string(), + structuredPatch: z.array(StructuredPatchSchema) + }).strict(), + + // update + z.object({ + filePath: z.string(), + oldString: z.string(), + newString: z.string(), + originalFile: z.string(), + userModified: z.boolean(), + replaceAll: z.boolean(), + structuredPatch: z.array(StructuredPatchSchema) + }).strict(), + + // search? + z.object({ + filenames: z.array(z.string()), + durationMs: z.number(), + numFiles: z.number(), + truncated: z.boolean(), + }).strict(), + + // text + z.object({ + type: z.literal('text'), + file: z.object({ + filePath: z.string(), + content: z.string(), + numLines: z.number(), + startLine: z.number(), + totalLines: z.number(), + }) + }).strict(), + + // content + z.object({ + mode: z.literal('content'), + numFiles: z.number(), + filenames: z.array(z.string()), + content: z.string(), + numLines: z.number(), + }).strict(), +]) diff --git a/src/lib/conversation-schema/tool/StructuredPatchSchema.ts b/src/lib/conversation-schema/tool/StructuredPatchSchema.ts new file mode 100644 index 0000000..8922a65 --- /dev/null +++ b/src/lib/conversation-schema/tool/StructuredPatchSchema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod' + +export const StructuredPatchSchema = z.object({ + oldStart: z.number(), + oldLines: z.number(), + newStart: z.number(), + newLines: z.number(), + lines: z.array(z.string()), +}).strict() diff --git a/src/lib/conversation-schema/tool/TodoSchema.ts b/src/lib/conversation-schema/tool/TodoSchema.ts new file mode 100644 index 0000000..61e16b4 --- /dev/null +++ b/src/lib/conversation-schema/tool/TodoSchema.ts @@ -0,0 +1,13 @@ +import z from "zod"; + +const TodoSchema = z.object({ + content: z.string(), + status: z.enum(['pending', 'in_progress', 'completed']), + priority: z.enum(['low', 'medium', 'high']), + id: z.string(), +}).strict() + +export const TodoToolResultSchema = z.object({ + oldTodos: z.array(TodoSchema).optional(), + newTodos: z.array(TodoSchema).optional(), +}).strict() diff --git a/src/lib/conversation-schema/tool/index.ts b/src/lib/conversation-schema/tool/index.ts new file mode 100644 index 0000000..48d2fa5 --- /dev/null +++ b/src/lib/conversation-schema/tool/index.ts @@ -0,0 +1,9 @@ +import { z } from "zod"; +import { TodoToolResultSchema } from "./TodoSchema"; +import { CommonToolResultSchema } from "./CommonToolSchema"; + +export const ToolUseResultSchema = z.union([ + z.string(), + TodoToolResultSchema, + CommonToolResultSchema, +]); diff --git a/src/server/hono/route.ts b/src/server/hono/route.ts index a1fbb10..70b590b 100644 --- a/src/server/hono/route.ts +++ b/src/server/hono/route.ts @@ -1,20 +1,32 @@ -import { zValidator } from "@hono/zod-validator"; -import { z } from "zod"; import type { HonoAppType } from "./app"; - -const helloBodySchema = z.object({ - name: z.string(), -}); +import { getProjects } from "../service/project/getProjects"; +import { getProject } from "../service/project/getProject"; +import { getSessions } from "../service/session/getSessions"; +import { getSession } from "../service/session/getSession"; export const routes = (app: HonoAppType) => { - return ( - app - // routes - .get("/hello", zValidator("json", helloBodySchema), (c) => { - const { name } = c.req.valid("json"); - return c.json({ message: `Hello ${name}` }); - }) - ); + return app + .get("/projects", async (c) => { + const { projects } = await getProjects(); + return c.json({ projects }); + }) + + .get("/projects/:projectId", async (c) => { + const { projectId } = c.req.param(); + + const [{ project }, { sessions }] = await Promise.all([ + getProject(projectId), + getSessions(projectId), + ] as const); + + return c.json({ project, sessions }); + }) + + .get("/projects/:projectId/sessions/:sessionId", async (c) => { + const { projectId, sessionId } = c.req.param(); + const { session } = await getSession(projectId, sessionId); + return c.json({ session }); + }); }; export type RouteType = ReturnType; diff --git a/src/server/service/parseCommandXml.ts b/src/server/service/parseCommandXml.ts new file mode 100644 index 0000000..d00388a --- /dev/null +++ b/src/server/service/parseCommandXml.ts @@ -0,0 +1,82 @@ +import { z } from "zod"; + +const regExp = /<(?[^>]+)>(?\s*[^<]*?\s*)<\/\k>/g; + +const matchSchema = z.object({ + tag: z.string(), + content: z.string(), +}); + +export const parseCommandXml = ( + content: string +): + | { + kind: "command"; + commandName: string; + commandArgs: string; + commandMessage?: string; + } + | { + kind: "local-command-1"; + commandName: string; + commandMessage: string; + } + | { + kind: "local-command-2"; + stdout: string; + } + | { + kind: "text"; + content: string; + } => { + const matches = Array.from(content.matchAll(regExp)) + .map((match) => matchSchema.safeParse(match.groups)) + .filter((result) => result.success) + .map((result) => result.data); + + if (matches.length === 0) { + return { + kind: "text", + content, + }; + } + + const commandName = matches.find( + (match) => match.tag === "command-name" + )?.content; + const commandArgs = matches.find( + (match) => match.tag === "command-args" + )?.content; + const commandMessage = matches.find( + (match) => match.tag === "command-message" + )?.content; + const localCommandStdout = matches.find( + (match) => match.tag === "local-command-stdout" + )?.content; + + switch (true) { + case commandName !== undefined && commandArgs !== undefined: + return { + kind: "command", + commandName, + commandArgs, + commandMessage: commandMessage, + }; + case commandName !== undefined && commandMessage !== undefined: + return { + kind: "local-command-1", + commandName, + commandMessage, + }; + case localCommandStdout !== undefined: + return { + kind: "local-command-2", + stdout: localCommandStdout, + }; + default: + return { + kind: "text", + content, + }; + } +}; diff --git a/src/server/service/parseJsonl.ts b/src/server/service/parseJsonl.ts new file mode 100644 index 0000000..a22a312 --- /dev/null +++ b/src/server/service/parseJsonl.ts @@ -0,0 +1,18 @@ +import { ConversationSchema } from "../../lib/conversation-schema"; + +export const parseJsonl = (content: string) => { + const lines = content + .trim() + .split("\n") + .filter((line) => line.trim() !== ""); + + return lines.flatMap((line) => { + const parsed = ConversationSchema.safeParse(JSON.parse(line)); + if (!parsed.success) { + console.warn("Failed to parse jsonl, skipping", parsed.error); + return []; + } + + return parsed.data; + }); +}; diff --git a/src/server/service/paths.ts b/src/server/service/paths.ts new file mode 100644 index 0000000..e87e728 --- /dev/null +++ b/src/server/service/paths.ts @@ -0,0 +1,4 @@ +import { homedir } from "node:os"; +import { resolve } from "node:path"; + +export const claudeProjectPath = resolve(homedir(), ".claude", "projects"); diff --git a/src/server/service/project/getProject.ts b/src/server/service/project/getProject.ts new file mode 100644 index 0000000..09b9e24 --- /dev/null +++ b/src/server/service/project/getProject.ts @@ -0,0 +1,24 @@ +import { existsSync } from "node:fs"; + +import type { Project } from "../types"; +import { decodeProjectId } from "./id"; +import { getProjectMeta } from "./getProjectMeta"; + +export const getProject = async ( + projectId: string +): Promise<{ project: Project }> => { + const fullPath = decodeProjectId(projectId); + if (!existsSync(fullPath)) { + throw new Error("Project not found"); + } + + const meta = await getProjectMeta(fullPath); + + return { + project: { + id: projectId, + claudeProjectPath: fullPath, + meta, + }, + }; +}; diff --git a/src/server/service/project/getProjectMeta.ts b/src/server/service/project/getProjectMeta.ts new file mode 100644 index 0000000..dadbd24 --- /dev/null +++ b/src/server/service/project/getProjectMeta.ts @@ -0,0 +1,83 @@ +import { statSync } from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; + +import { parseJsonl } from "../parseJsonl"; +import type { ProjectMeta } from "../types"; + +const projectMetaCache = new Map(); + +const extractMetaFromJsonl = async (filePath: string) => { + const content = await readFile(filePath, "utf-8"); + const lines = content.split("\n"); + + let cwd: string | null = null; + + for (const line of lines) { + const conversation = parseJsonl(line).at(0); + + if (conversation === undefined || conversation.type === "summary") { + continue; + } + + cwd = conversation.cwd; + + break; + } + + return { + cwd, + } as const; +}; + +export const getProjectMeta = async ( + claudeProjectPath: string +): Promise => { + const cached = projectMetaCache.get(claudeProjectPath); + if (cached !== undefined) { + return cached; + } + + const dirents = await readdir(claudeProjectPath, { withFileTypes: true }); + const files = dirents + .filter((d) => d.isFile() && d.name.endsWith(".jsonl")) + .map( + (d) => + ({ + fullPath: resolve(d.parentPath, d.name), + stats: statSync(resolve(d.parentPath, d.name)), + } as const) + ) + .toSorted((a, b) => { + return a.stats.ctime.getTime() - b.stats.ctime.getTime(); + }); + + const lastModifiedUnixTime = files.at(-1)?.stats.ctime.getTime(); + + let cwd: string | null = null; + + for (const file of files) { + const result = await extractMetaFromJsonl(file.fullPath); + + if (result.cwd === null) { + continue; + } + + cwd = result.cwd; + + break; + } + + const projectMeta: ProjectMeta = { + projectName: cwd ? dirname(cwd) : null, + projectPath: cwd, + lastModifiedAt: lastModifiedUnixTime + ? new Date(lastModifiedUnixTime) + : null, + sessionCount: files.length, + }; + + projectMetaCache.set(claudeProjectPath, projectMeta); + + return projectMeta; +}; diff --git a/src/server/service/project/getProjects.ts b/src/server/service/project/getProjects.ts new file mode 100644 index 0000000..731c5cf --- /dev/null +++ b/src/server/service/project/getProjects.ts @@ -0,0 +1,29 @@ +import { readdir } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { claudeProjectPath } from "../paths"; +import type { Project } from "../types"; +import { encodeProjectId } from "./id"; +import { getProjectMeta } from "./getProjectMeta"; + +export const getProjects = async (): Promise<{ projects: Project[] }> => { + const dirents = await readdir(claudeProjectPath, { withFileTypes: true }); + const projects = await Promise.all( + dirents + .filter((d) => d.isDirectory()) + .map(async (d) => { + const fullPath = resolve(d.parentPath, d.name); + const id = encodeProjectId(fullPath); + + return { + id, + claudeProjectPath: fullPath, + meta: await getProjectMeta(fullPath), + }; + }) + ); + + return { + projects, + }; +}; diff --git a/src/server/service/project/id.ts b/src/server/service/project/id.ts new file mode 100644 index 0000000..5b9f64c --- /dev/null +++ b/src/server/service/project/id.ts @@ -0,0 +1,7 @@ +export const encodeProjectId = (fullPath: string) => { + return Buffer.from(fullPath).toString("base64url"); +}; + +export const decodeProjectId = (id: string) => { + return Buffer.from(id, "base64url").toString("utf-8"); +}; diff --git a/src/server/service/session/getSession.ts b/src/server/service/session/getSession.ts new file mode 100644 index 0000000..509f078 --- /dev/null +++ b/src/server/service/session/getSession.ts @@ -0,0 +1,31 @@ +import { readFile } from "node:fs/promises"; +import { decodeProjectId } from "../project/id"; +import { resolve } from "node:path"; +import { parseJsonl } from "../parseJsonl"; +import type { SessionDetail } from "../types"; +import { getSessionMeta } from "./getSessionMeta"; + +export const getSession = async ( + projectId: string, + sessionId: string +): Promise<{ + session: SessionDetail; +}> => { + const projectPath = decodeProjectId(projectId); + const sessionPath = resolve(projectPath, `${sessionId}.jsonl`); + + const content = await readFile(sessionPath, "utf-8"); + + const conversations = parseJsonl(content); + + const sessionDetail: SessionDetail = { + id: sessionId, + jsonlFilePath: sessionPath, + meta: await getSessionMeta(sessionPath), + conversations, + }; + + return { + session: sessionDetail, + }; +}; diff --git a/src/server/service/session/getSessionMeta.ts b/src/server/service/session/getSessionMeta.ts new file mode 100644 index 0000000..bbc371c --- /dev/null +++ b/src/server/service/session/getSessionMeta.ts @@ -0,0 +1,60 @@ +import { statSync } from "node:fs"; +import { readFile } from "node:fs/promises"; + +import { parseJsonl } from "../parseJsonl"; +import type { Conversation } from "../../../lib/conversation-schema"; +import type { SessionMeta } from "../types"; + +const sessionMetaCache = new Map(); + +export const getSessionMeta = async ( + jsonlFilePath: string +): Promise => { + const cached = sessionMetaCache.get(jsonlFilePath); + if (cached !== undefined) { + return cached; + } + + const stats = statSync(jsonlFilePath); + const lastModifiedUnixTime = stats.ctime.getTime(); + + const content = await readFile(jsonlFilePath, "utf-8"); + const lines = content.split("\n"); + + let firstUserMessage: Conversation | null = null; + + for (const line of lines) { + const conversation = parseJsonl(line).at(0); + + if (conversation === undefined || conversation.type !== "user") { + continue; + } + + firstUserMessage = conversation; + + break; + } + + const sessionMeta: SessionMeta = { + messageCount: lines.length, + firstContent: + firstUserMessage === null + ? null + : typeof firstUserMessage.message.content === "string" + ? firstUserMessage.message.content + : (() => { + const firstContent = firstUserMessage.message.content.at(0); + if (firstContent === undefined) return null; + if (typeof firstContent === "string") return firstContent; + if (firstContent.type === "text") return firstContent.text; + return null; + })(), + lastModifiedAt: lastModifiedUnixTime + ? new Date(lastModifiedUnixTime) + : null, + }; + + sessionMetaCache.set(jsonlFilePath, sessionMeta); + + return sessionMeta; +}; diff --git a/src/server/service/session/getSessions.ts b/src/server/service/session/getSessions.ts new file mode 100644 index 0000000..31e1f72 --- /dev/null +++ b/src/server/service/session/getSessions.ts @@ -0,0 +1,31 @@ +import { readdir } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { decodeProjectId } from "../project/id"; +import type { Session } from "../types"; +import { getSessionMeta } from "./getSessionMeta"; + +export const getSessions = async ( + projectId: string +): Promise<{ sessions: Session[] }> => { + const claudeProjectPath = decodeProjectId(projectId); + + const dirents = await readdir(claudeProjectPath, { withFileTypes: true }); + const sessions = await Promise.all( + dirents + .filter((d) => d.isFile() && d.name.endsWith(".jsonl")) + .map(async (d): Promise => { + const fullPath = resolve(d.parentPath, d.name); + + return { + id: d.name, + jsonlFilePath: fullPath, + meta: await getSessionMeta(fullPath), + }; + }) + ); + + return { + sessions, + }; +}; diff --git a/src/server/service/types.ts b/src/server/service/types.ts new file mode 100644 index 0000000..9e120fc --- /dev/null +++ b/src/server/service/types.ts @@ -0,0 +1,30 @@ +import type { Conversation } from "../../lib/conversation-schema"; + +export type Project = { + id: string; + claudeProjectPath: string; + meta: ProjectMeta; +}; + +export type ProjectMeta = { + projectName: string | null; + projectPath: string | null; + lastModifiedAt: Date | null; + sessionCount: number; +}; + +export type Session = { + id: string; + jsonlFilePath: string; + meta: SessionMeta; +}; + +export type SessionMeta = { + messageCount: number; + firstContent: string | null; + lastModifiedAt: Date | null; +}; + +export type SessionDetail = Session & { + conversations: Conversation[]; +}; diff --git a/tsconfig.json b/tsconfig.json index 37ffbcc..ed4ef0a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "extends": "@tsconfig/strictest/tsconfig.json", "compilerOptions": { // transpile target - "target": "ES2017", + "target": "ES2020", "lib": ["dom", "dom.iterable", "esnext"], "noEmit": true, // module