diff --git a/src/app/api/[[...route]]/route.ts b/src/app/api/[[...route]]/route.ts index 63e2ac0..76f4e5e 100644 --- a/src/app/api/[[...route]]/route.ts +++ b/src/app/api/[[...route]]/route.ts @@ -2,7 +2,7 @@ import { handle } from "hono/vercel"; import { honoApp } from "../../../server/hono/app"; import { routes } from "../../../server/hono/route"; -routes(honoApp); +await routes(honoApp); export const GET = handle(honoApp); export const POST = handle(honoApp); diff --git a/src/server/hono/initialize.ts b/src/server/hono/initialize.ts new file mode 100644 index 0000000..d0d79a0 --- /dev/null +++ b/src/server/hono/initialize.ts @@ -0,0 +1,41 @@ +import { eventBus } from "../service/events/EventBus"; +import { fileWatcher } from "../service/events/fileWatcher"; +import type { ProjectRepository } from "../service/project/ProjectRepository"; +import { projectMetaStorage } from "../service/project/projectMetaStorage"; +import type { SessionRepository } from "../service/session/SessionRepository"; +import { sessionMetaStorage } from "../service/session/sessionMetaStorage"; + +export const initialize = async (deps: { + sessionRepository: SessionRepository; + projectRepository: ProjectRepository; +}): Promise => { + fileWatcher.startWatching(); + + setInterval(() => { + eventBus.emit("heartbeat", {}); + }, 10 * 1000); + + eventBus.on("sessionChanged", (event) => { + projectMetaStorage.invalidateProject(event.projectId); + sessionMetaStorage.invalidateSession(event.projectId, event.sessionId); + }); + + try { + console.log("Initializing projects cache"); + const { projects } = await deps.projectRepository.getProjects(); + console.log(`${projects.length} projects cache initialized`); + + console.log("Initializing sessions cache"); + const results = await Promise.all( + projects.map((project) => deps.sessionRepository.getSessions(project.id)) + ); + console.log( + `${results.reduce( + (s, { sessions }) => s + sessions.length, + 0 + )} sessions cache initialized` + ); + } catch { + // do nothing + } +}; diff --git a/src/server/hono/route.ts b/src/server/hono/route.ts index 14e48de..e033cc9 100644 --- a/src/server/hono/route.ts +++ b/src/server/hono/route.ts @@ -9,8 +9,7 @@ import { env } from "../lib/env"; import { ClaudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController"; import type { SerializableAliveTask } from "../service/claude-code/types"; import { adaptInternalEventToSSE } from "../service/events/adaptInternalEventToSSE"; -import { getEventBus } from "../service/events/EventBus"; -import { getFileWatcher } from "../service/events/fileWatcher"; +import { eventBus } from "../service/events/EventBus"; import type { InternalEventDeclaration } from "../service/events/InternalEventDeclaration"; import { writeTypeSafeSSE } from "../service/events/typeSafeSSE"; import { getFileCompletion } from "../service/file-completion/getFileCompletion"; @@ -19,14 +18,13 @@ import { getCommits } from "../service/git/getCommits"; import { getDiff } from "../service/git/getDiff"; import { getMcpList } from "../service/mcp/getMcpList"; import { claudeCommandsDirPath } from "../service/paths"; -import { getProject } from "../service/project/getProject"; -import { getProjects } from "../service/project/getProjects"; -import { getSession } from "../service/session/getSession"; -import { getSessions } from "../service/session/getSessions"; +import { ProjectRepository } from "../service/project/ProjectRepository"; +import { SessionRepository } from "../service/session/SessionRepository"; import type { HonoAppType } from "./app"; +import { initialize } from "./initialize"; import { configMiddleware } from "./middleware/config.middleware"; -export const routes = (app: HonoAppType) => { +export const routes = async (app: HonoAppType) => { let taskController: ClaudeCodeTaskController | null = null; const getTaskController = (config: Config) => { if (!taskController) { @@ -37,15 +35,14 @@ export const routes = (app: HonoAppType) => { return taskController; }; - const fileWatcher = getFileWatcher(); - const eventBus = getEventBus(); + const sessionRepository = new SessionRepository(); + const projectRepository = new ProjectRepository(); if (env.get("NEXT_PHASE") !== "phase-production-build") { - fileWatcher.startWatching(); - - setInterval(() => { - eventBus.emit("heartbeat", {}); - }, 10 * 1000); + await initialize({ + sessionRepository, + projectRepository, + }); } return ( @@ -71,7 +68,7 @@ export const routes = (app: HonoAppType) => { }) .get("/projects", async (c) => { - const { projects } = await getProjects(); + const { projects } = await projectRepository.getProjects(); return c.json({ projects }); }) @@ -79,8 +76,8 @@ export const routes = (app: HonoAppType) => { const { projectId } = c.req.param(); const [{ project }, { sessions }] = await Promise.all([ - getProject(projectId), - getSessions(projectId).then(({ sessions }) => { + projectRepository.getProject(projectId), + sessionRepository.getSessions(projectId).then(({ sessions }) => { let filteredSessions = sessions; // Filter sessions based on hideNoUserMessageSession setting @@ -157,7 +154,10 @@ export const routes = (app: HonoAppType) => { .get("/projects/:projectId/sessions/:sessionId", async (c) => { const { projectId, sessionId } = c.req.param(); - const { session } = await getSession(projectId, sessionId); + const { session } = await sessionRepository.getSession( + projectId, + sessionId, + ); return c.json({ session }); }) @@ -173,7 +173,7 @@ export const routes = (app: HonoAppType) => { const { projectId } = c.req.param(); const { basePath } = c.req.valid("query"); - const { project } = await getProject(projectId); + const { project } = await projectRepository.getProject(projectId); if (project.meta.projectPath === null) { return c.json({ error: "Project path not found" }, 400); @@ -194,7 +194,7 @@ export const routes = (app: HonoAppType) => { .get("/projects/:projectId/claude-commands", async (c) => { const { projectId } = c.req.param(); - const { project } = await getProject(projectId); + const { project } = await projectRepository.getProject(projectId); const [globalCommands, projectCommands] = await Promise.allSettled([ readdir(claudeCommandsDirPath, { @@ -229,7 +229,7 @@ export const routes = (app: HonoAppType) => { .get("/projects/:projectId/git/branches", async (c) => { const { projectId } = c.req.param(); - const { project } = await getProject(projectId); + const { project } = await projectRepository.getProject(projectId); if (project.meta.projectPath === null) { return c.json({ error: "Project path not found" }, 400); @@ -249,7 +249,7 @@ export const routes = (app: HonoAppType) => { .get("/projects/:projectId/git/commits", async (c) => { const { projectId } = c.req.param(); - const { project } = await getProject(projectId); + const { project } = await projectRepository.getProject(projectId); if (project.meta.projectPath === null) { return c.json({ error: "Project path not found" }, 400); @@ -279,7 +279,7 @@ export const routes = (app: HonoAppType) => { async (c) => { const { projectId } = c.req.param(); const { fromRef, toRef } = c.req.valid("json"); - const { project } = await getProject(projectId); + const { project } = await projectRepository.getProject(projectId); if (project.meta.projectPath === null) { return c.json({ error: "Project path not found" }, 400); @@ -318,7 +318,7 @@ export const routes = (app: HonoAppType) => { async (c) => { const { projectId } = c.req.param(); const { message } = c.req.valid("json"); - const { project } = await getProject(projectId); + const { project } = await projectRepository.getProject(projectId); if (project.meta.projectPath === null) { return c.json({ error: "Project path not found" }, 400); @@ -353,7 +353,7 @@ export const routes = (app: HonoAppType) => { async (c) => { const { projectId, sessionId } = c.req.param(); const { resumeMessage } = c.req.valid("json"); - const { project } = await getProject(projectId); + const { project } = await projectRepository.getProject(projectId); if (project.meta.projectPath === null) { return c.json({ error: "Project path not found" }, 400); @@ -473,4 +473,4 @@ export const routes = (app: HonoAppType) => { ); }; -export type RouteType = ReturnType; +export type RouteType = Awaited>; diff --git a/src/server/lib/storage/FileCacheStorage.ts b/src/server/lib/storage/FileCacheStorage.ts new file mode 100644 index 0000000..34d71fc --- /dev/null +++ b/src/server/lib/storage/FileCacheStorage.ts @@ -0,0 +1,82 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { writeFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { z } from "zod"; +import { claudeCodeViewerCacheDirPath } from "../../service/paths"; + +const saveSchema = z.array(z.tuple([z.string(), z.unknown()])); + +export class FileCacheStorage { + private storage = new Map(); + + private constructor(private readonly key: string) {} + + public static load( + key: string, + schema: z.ZodType + ) { + const instance = new FileCacheStorage(key); + + if (!existsSync(claudeCodeViewerCacheDirPath)) { + mkdirSync(claudeCodeViewerCacheDirPath, { recursive: true }); + } + + if (!existsSync(instance.cacheFilePath)) { + writeFileSync(instance.cacheFilePath, "[]"); + } else { + const content = readFileSync(instance.cacheFilePath, "utf-8"); + const parsed = saveSchema.safeParse(JSON.parse(content)); + + if (!parsed.success) { + writeFileSync(instance.cacheFilePath, "[]"); + } else { + for (const [key, value] of parsed.data) { + const parsedValue = schema.safeParse(value); + if (!parsedValue.success) { + continue; + } + + instance.storage.set(key, parsedValue.data); + } + } + } + + return instance; + } + + private get cacheFilePath() { + return resolve(claudeCodeViewerCacheDirPath, `${this.key}.json`); + } + + private asSaveFormat() { + return JSON.stringify(Array.from(this.storage.entries())); + } + + private async syncToFile() { + await writeFile(this.cacheFilePath, this.asSaveFormat()); + } + + public get(key: string) { + return this.storage.get(key); + } + + public save(key: string, value: T) { + const previous = this.asSaveFormat(); + this.storage.set(key, value); + + if (previous === this.asSaveFormat()) { + return; + } + + void this.syncToFile(); + } + + public invalidate(key: string) { + if (!this.storage.has(key)) { + return; + } + + this.storage.delete(key); + void this.syncToFile(); + } +} diff --git a/src/server/lib/storage/InMemoryCacheStorage.ts b/src/server/lib/storage/InMemoryCacheStorage.ts new file mode 100644 index 0000000..5433d07 --- /dev/null +++ b/src/server/lib/storage/InMemoryCacheStorage.ts @@ -0,0 +1,21 @@ +export class InMemoryCacheStorage { + private storage = new Map(); + + public constructor() {} + + public get(key: string) { + return this.storage.get(key); + } + + public save(key: string, value: T) { + this.storage.set(key, value); + } + + public invalidate(key: string) { + if (!this.storage.has(key)) { + return; + } + + this.storage.delete(key); + } +} diff --git a/src/server/service/claude-code/ClaudeCodeTaskController.ts b/src/server/service/claude-code/ClaudeCodeTaskController.ts index a097f6b..765f026 100644 --- a/src/server/service/claude-code/ClaudeCodeTaskController.ts +++ b/src/server/service/claude-code/ClaudeCodeTaskController.ts @@ -1,7 +1,7 @@ import prexit from "prexit"; import { ulid } from "ulid"; import type { Config } from "../../config/config"; -import { getEventBus, type IEventBus } from "../events/EventBus"; +import { eventBus } from "../events/EventBus"; import { ClaudeCodeExecutor } from "./ClaudeCodeExecutor"; import { createMessageGenerator } from "./createMessageGenerator"; import type { @@ -16,14 +16,12 @@ import type { export class ClaudeCodeTaskController { private claudeCode: ClaudeCodeExecutor; private tasks: ClaudeCodeTask[] = []; - private eventBus: IEventBus; private config: Config; private pendingPermissionRequests: Map = new Map(); private permissionResponses: Map = new Map(); constructor(config: Config) { this.claudeCode = new ClaudeCodeExecutor(); - this.eventBus = getEventBus(); this.config = config; prexit(() => { @@ -85,7 +83,7 @@ export class ClaudeCodeTaskController { ); // Emit event to notify UI - this.eventBus.emit("permissionRequested", { + eventBus.emit("permissionRequested", { permissionRequest, }); @@ -389,7 +387,7 @@ export class ClaudeCodeTaskController { } if (task.status === "paused" || task.status === "running") { - this.eventBus.emit("taskChanged", { + eventBus.emit("taskChanged", { aliveTasks: this.aliveTasks, changed: task, }); diff --git a/src/server/service/events/EventBus.ts b/src/server/service/events/EventBus.ts index 42fa9e4..9bc0a2b 100644 --- a/src/server/service/events/EventBus.ts +++ b/src/server/service/events/EventBus.ts @@ -36,12 +36,4 @@ class EventBus { } } -// singleton -let eventBus: EventBus | null = null; - -export const getEventBus = () => { - eventBus ??= new EventBus(); - return eventBus; -}; - -export type IEventBus = ReturnType; +export const eventBus = new EventBus(); diff --git a/src/server/service/events/adaptInternalEventToSSE.ts b/src/server/service/events/adaptInternalEventToSSE.ts index e4bf7c8..9e53c41 100644 --- a/src/server/service/events/adaptInternalEventToSSE.ts +++ b/src/server/service/events/adaptInternalEventToSSE.ts @@ -1,5 +1,5 @@ import type { SSEStreamingApi } from "hono/streaming"; -import { getEventBus } from "./EventBus"; +import { eventBus } from "./EventBus"; import type { InternalEventDeclaration } from "./InternalEventDeclaration"; import { writeTypeSafeSSE } from "./typeSafeSSE"; @@ -14,8 +14,6 @@ export const adaptInternalEventToSSE = ( console.log("SSE connection started"); - const eventBus = getEventBus(); - const stream = writeTypeSafeSSE(rawStream); const abortController = new AbortController(); diff --git a/src/server/service/events/fileWatcher.ts b/src/server/service/events/fileWatcher.ts index 290de2c..0871678 100644 --- a/src/server/service/events/fileWatcher.ts +++ b/src/server/service/events/fileWatcher.ts @@ -1,7 +1,7 @@ import { type FSWatcher, watch } from "node:fs"; import z from "zod"; import { claudeProjectsDirPath } from "../paths"; -import { getEventBus, type IEventBus } from "./EventBus"; +import { eventBus } from "./EventBus"; const fileRegExp = /(?.*?)\/(?.*?)\.jsonl/; const fileRegExpGroupSchema = z.object({ @@ -13,11 +13,6 @@ export class FileWatcherService { private isWatching = false; private watcher: FSWatcher | null = null; private projectWatchers: Map = new Map(); - private eventBus: IEventBus; - - constructor() { - this.eventBus = getEventBus(); - } public startWatching(): void { if (this.isWatching) return; @@ -42,13 +37,13 @@ export class FileWatcherService { if (eventType === "change") { // セッションファイルの中身が変更されている - this.eventBus.emit("sessionChanged", { + eventBus.emit("sessionChanged", { projectId, sessionId, }); } else if (eventType === "rename") { // セッションファイルの追加/削除 - this.eventBus.emit("sessionListChanged", { + eventBus.emit("sessionListChanged", { projectId, }); } else { @@ -75,13 +70,4 @@ export class FileWatcherService { } } -// シングルトンインスタンス -let watcherInstance: FileWatcherService | null = null; - -export const getFileWatcher = (): FileWatcherService => { - if (!watcherInstance) { - console.log("Creating new FileWatcher instance"); - watcherInstance = new FileWatcherService(); - } - return watcherInstance; -}; +export const fileWatcher = new FileWatcherService(); diff --git a/src/server/service/parseCommandXml.ts b/src/server/service/parseCommandXml.ts index 18a436c..fa7dca5 100644 --- a/src/server/service/parseCommandXml.ts +++ b/src/server/service/parseCommandXml.ts @@ -7,21 +7,24 @@ const matchSchema = z.object({ content: z.string(), }); -export type ParsedCommand = - | { - kind: "command"; - commandName: string; - commandArgs?: string; - commandMessage?: string; - } - | { - kind: "local-command"; - stdout: string; - } - | { - kind: "text"; - content: string; - }; +export const parsedCommandSchema = z.union([ + z.object({ + kind: z.literal("command"), + commandName: z.string(), + commandArgs: z.string().optional(), + commandMessage: z.string().optional(), + }), + z.object({ + kind: z.literal("local-command"), + stdout: z.string(), + }), + z.object({ + kind: z.literal("text"), + content: z.string(), + }), +]); + +export type ParsedCommand = z.infer; export const parseCommandXml = (content: string): ParsedCommand => { const matches = Array.from(content.matchAll(regExp)) diff --git a/src/server/service/paths.ts b/src/server/service/paths.ts index 0344968..4a89509 100644 --- a/src/server/service/paths.ts +++ b/src/server/service/paths.ts @@ -18,3 +18,9 @@ export const claudeCommandsDirPath = resolve( globalClaudeDirectoryPath, "commands", ); + +export const claudeCodeViewerCacheDirPath = resolve( + homedir(), + ".claude-code-viewer", + "cache", +); diff --git a/src/server/service/project/ProjectRepository.ts b/src/server/service/project/ProjectRepository.ts new file mode 100644 index 0000000..d6d7055 --- /dev/null +++ b/src/server/service/project/ProjectRepository.ts @@ -0,0 +1,75 @@ +import { existsSync } from "node:fs"; +import { access, constants, readdir } from "node:fs/promises"; +import { resolve } from "node:path"; +import { claudeProjectsDirPath } from "../paths"; +import type { Project } from "../types"; +import { decodeProjectId, encodeProjectId } from "./id"; +import { projectMetaStorage } from "./projectMetaStorage"; + +export class ProjectRepository { + public async getProject(projectId: string): Promise<{ project: Project }> { + const fullPath = decodeProjectId(projectId); + if (!existsSync(fullPath)) { + throw new Error("Project not found"); + } + + const meta = await projectMetaStorage.getProjectMeta(projectId); + + return { + project: { + id: projectId, + claudeProjectPath: fullPath, + meta, + }, + }; + } + + public async getProjects(): Promise<{ projects: Project[] }> { + try { + // Check if the claude projects directory exists + await access(claudeProjectsDirPath, constants.F_OK); + } catch (_error) { + // Directory doesn't exist, return empty array + console.warn( + `Claude projects directory not found at ${claudeProjectsDirPath}`, + ); + return { projects: [] }; + } + + try { + const dirents = await readdir(claudeProjectsDirPath, { + withFileTypes: true, + }); + const projects = await Promise.all( + dirents + .filter((d) => d.isDirectory()) + .map(async (d) => { + const fullPath = resolve(claudeProjectsDirPath, d.name); + const id = encodeProjectId(fullPath); + + return { + id, + claudeProjectPath: fullPath, + meta: await projectMetaStorage.getProjectMeta(id), + }; + }), + ); + + return { + projects: projects.sort((a, b) => { + return ( + (b.meta.lastModifiedAt + ? new Date(b.meta.lastModifiedAt).getTime() + : 0) - + (a.meta.lastModifiedAt + ? new Date(a.meta.lastModifiedAt).getTime() + : 0) + ); + }), + }; + } catch (error) { + console.error("Error reading projects:", error); + return { projects: [] }; + } + } +} diff --git a/src/server/service/project/getProject.ts b/src/server/service/project/getProject.ts deleted file mode 100644 index 4af646d..0000000 --- a/src/server/service/project/getProject.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { existsSync } from "node:fs"; - -import type { Project } from "../types"; -import { getProjectMeta } from "./getProjectMeta"; -import { decodeProjectId } from "./id"; - -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 deleted file mode 100644 index 7867f6d..0000000 --- a/src/server/service/project/getProjectMeta.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { statSync } from "node:fs"; -import { readdir, readFile } from "node:fs/promises"; -import { basename, resolve } from "node:path"; - -import { parseJsonl } from "../parseJsonl"; -import type { ProjectMeta } from "../types"; - -const projectPathCache = new Map(); - -const extractProjectPathFromJsonl = async (filePath: string) => { - const cached = projectPathCache.get(filePath); - if (cached !== undefined) { - return cached; - } - - 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" || - conversation.type === "x-error" - ) { - continue; - } - - cwd = conversation.cwd; - - break; - } - - if (cwd !== null) { - projectPathCache.set(filePath, cwd); - } - - return cwd; -}; - -export const getProjectMeta = async ( - claudeProjectPath: string, -): Promise => { - const dirents = await readdir(claudeProjectPath, { withFileTypes: true }); - const files = dirents - .filter((d) => d.isFile() && d.name.endsWith(".jsonl")) - .map( - (d) => - ({ - fullPath: resolve(claudeProjectPath, d.name), - stats: statSync(resolve(claudeProjectPath, d.name)), - }) as const, - ) - .sort((a, b) => { - return a.stats.ctime.getTime() - b.stats.ctime.getTime(); - }); - - const lastModifiedUnixTime = files.at(-1)?.stats.ctime.getTime(); - - let projectPath: string | null = null; - - for (const file of files) { - projectPath = await extractProjectPathFromJsonl(file.fullPath); - - if (projectPath === null) { - continue; - } - - break; - } - - const projectMeta: ProjectMeta = { - projectName: projectPath ? basename(projectPath) : null, - projectPath, - lastModifiedAt: lastModifiedUnixTime - ? new Date(lastModifiedUnixTime) - : null, - sessionCount: files.length, - }; - - return projectMeta; -}; diff --git a/src/server/service/project/getProjects.ts b/src/server/service/project/getProjects.ts deleted file mode 100644 index 9396033..0000000 --- a/src/server/service/project/getProjects.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { constants } from "node:fs"; -import { access, readdir } from "node:fs/promises"; -import { resolve } from "node:path"; -import { claudeProjectsDirPath } from "../paths"; -import type { Project } from "../types"; -import { getProjectMeta } from "./getProjectMeta"; -import { encodeProjectId } from "./id"; - -export const getProjects = async (): Promise<{ projects: Project[] }> => { - try { - // Check if the claude projects directory exists - await access(claudeProjectsDirPath, constants.F_OK); - } catch (_error) { - // Directory doesn't exist, return empty array - console.warn( - `Claude projects directory not found at ${claudeProjectsDirPath}`, - ); - return { projects: [] }; - } - - try { - const dirents = await readdir(claudeProjectsDirPath, { - withFileTypes: true, - }); - const projects = await Promise.all( - dirents - .filter((d) => d.isDirectory()) - .map(async (d) => { - const fullPath = resolve(claudeProjectsDirPath, d.name); - const id = encodeProjectId(fullPath); - - return { - id, - claudeProjectPath: fullPath, - meta: await getProjectMeta(fullPath), - }; - }), - ); - - return { - projects: projects.sort((a, b) => { - return ( - (b.meta.lastModifiedAt?.getTime() ?? 0) - - (a.meta.lastModifiedAt?.getTime() ?? 0) - ); - }), - }; - } catch (error) { - console.error("Error reading projects:", error); - return { projects: [] }; - } -}; diff --git a/src/server/service/project/projectMetaStorage.ts b/src/server/service/project/projectMetaStorage.ts new file mode 100644 index 0000000..63d616f --- /dev/null +++ b/src/server/service/project/projectMetaStorage.ts @@ -0,0 +1,109 @@ +import { statSync } from "node:fs"; +import { readdir, readFile } from "node:fs/promises"; +import { basename, resolve } from "node:path"; +import { z } from "zod"; +import { FileCacheStorage } from "../../lib/storage/FileCacheStorage"; +import { parseJsonl } from "../parseJsonl"; +import type { ProjectMeta } from "../types"; +import { decodeProjectId } from "./id"; +import { InMemoryCacheStorage } from "../../lib/storage/InMemoryCacheStorage"; + +class ProjectMetaStorage { + private projectPathCache = FileCacheStorage.load( + "project-path-cache", + z.string().nullable() + ); + private projectMetaCache = new InMemoryCacheStorage(); + + public async getProjectMeta(projectId: string): Promise { + const cached = this.projectMetaCache.get(projectId); + if (cached !== undefined) { + return cached; + } + + const claudeProjectPath = decodeProjectId(projectId); + + const dirents = await readdir(claudeProjectPath, { withFileTypes: true }); + const files = dirents + .filter((d) => d.isFile() && d.name.endsWith(".jsonl")) + .map( + (d) => + ({ + fullPath: resolve(claudeProjectPath, d.name), + stats: statSync(resolve(claudeProjectPath, d.name)), + } as const) + ) + .sort((a, b) => { + return a.stats.mtime.getTime() - b.stats.mtime.getTime(); + }); + + const lastModifiedUnixTime = files.at(-1)?.stats.mtime.getTime(); + + let projectPath: string | null = null; + + for (const file of files) { + projectPath = await this.extractProjectPathFromJsonl(file.fullPath); + + if (projectPath === null) { + continue; + } + + break; + } + + const projectMeta: ProjectMeta = { + projectName: projectPath ? basename(projectPath) : null, + projectPath, + lastModifiedAt: lastModifiedUnixTime + ? new Date(lastModifiedUnixTime).toISOString() + : null, + sessionCount: files.length, + }; + + this.projectMetaCache.save(projectId, projectMeta); + + return projectMeta; + } + + public invalidateProject(projectId: string) { + this.projectMetaCache.invalidate(projectId); + } + + private async extractProjectPathFromJsonl( + filePath: string + ): Promise { + const cached = this.projectPathCache.get(filePath); + if (cached !== undefined) { + return cached; + } + + 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" || + conversation.type === "x-error" + ) { + continue; + } + + cwd = conversation.cwd; + + break; + } + + if (cwd !== null) { + this.projectPathCache.save(filePath, cwd); + } + + return cwd; + } +} + +export const projectMetaStorage = new ProjectMetaStorage(); diff --git a/src/server/service/schema.ts b/src/server/service/schema.ts new file mode 100644 index 0000000..3060cf5 --- /dev/null +++ b/src/server/service/schema.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; +import { parsedCommandSchema } from "./parseCommandXml"; + +export const projectMetaSchema = z.object({ + projectName: z.string().nullable(), + projectPath: z.string().nullable(), + lastModifiedAt: z.string().nullable(), + sessionCount: z.number(), +}); + +export const sessionMetaSchema = z.object({ + messageCount: z.number(), + firstCommand: parsedCommandSchema.nullable(), + lastModifiedAt: z.string().nullable(), +}); diff --git a/src/server/service/session/SessionRepository.ts b/src/server/service/session/SessionRepository.ts new file mode 100644 index 0000000..7242025 --- /dev/null +++ b/src/server/service/session/SessionRepository.ts @@ -0,0 +1,69 @@ +import { readdir, readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { parseJsonl } from "../parseJsonl"; +import { decodeProjectId } from "../project/id"; +import type { Session, SessionDetail } from "../types"; +import { decodeSessionId, encodeSessionId } from "./id"; +import { sessionMetaStorage } from "./sessionMetaStorage"; + +const getTime = (date: string | null) => { + if (date === null) return 0; + return new Date(date).getTime(); +}; + +export class SessionRepository { + public async getSession( + projectId: string, + sessionId: string, + ): Promise<{ + session: SessionDetail; + }> { + const sessionPath = decodeSessionId(projectId, sessionId); + const content = await readFile(sessionPath, "utf-8"); + + const conversations = parseJsonl(content); + + const sessionDetail: SessionDetail = { + id: sessionId, + jsonlFilePath: sessionPath, + meta: await sessionMetaStorage.getSessionMeta(projectId, sessionId), + conversations, + }; + + return { + session: sessionDetail, + }; + } + + public async getSessions( + projectId: string, + ): Promise<{ sessions: Session[] }> { + try { + 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) => ({ + id: encodeSessionId(resolve(claudeProjectPath, d.name)), + jsonlFilePath: resolve(claudeProjectPath, d.name), + meta: await sessionMetaStorage.getSessionMeta( + projectId, + encodeSessionId(resolve(claudeProjectPath, d.name)), + ), + })), + ); + + return { + sessions: sessions.sort((a, b) => { + return ( + getTime(b.meta.lastModifiedAt) - getTime(a.meta.lastModifiedAt) + ); + }), + }; + } catch (error) { + console.warn(`Failed to read sessions for project ${projectId}:`, error); + return { sessions: [] }; + } + } +} diff --git a/src/server/service/session/getSession.ts b/src/server/service/session/getSession.ts deleted file mode 100644 index 7c7e612..0000000 --- a/src/server/service/session/getSession.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; -import { parseJsonl } from "../parseJsonl"; -import { decodeProjectId } from "../project/id"; -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 deleted file mode 100644 index 2ca9aac..0000000 --- a/src/server/service/session/getSessionMeta.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { statSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { type ParsedCommand, parseCommandXml } from "../parseCommandXml"; -import { parseJsonl } from "../parseJsonl"; -import type { SessionMeta } from "../types"; - -const firstCommandCache = new Map(); - -const ignoreCommands = [ - "/clear", - "/login", - "/logout", - "/exit", - "/mcp", - "/memory", -]; - -const getFirstCommand = ( - jsonlFilePath: string, - lines: string[], -): ParsedCommand | null => { - const cached = firstCommandCache.get(jsonlFilePath); - if (cached !== undefined) { - return cached; - } - - let firstCommand: ParsedCommand | null = null; - - for (const line of lines) { - const conversation = parseJsonl(line).at(0); - - if (conversation === undefined || conversation.type !== "user") { - continue; - } - - const firstUserText = - conversation === null - ? null - : typeof conversation.message.content === "string" - ? conversation.message.content - : (() => { - const firstContent = conversation.message.content.at(0); - if (firstContent === undefined) return null; - if (typeof firstContent === "string") return firstContent; - if (firstContent.type === "text") return firstContent.text; - return null; - })(); - - if (firstUserText === null) { - continue; - } - - if ( - firstUserText === - "Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to." - ) { - continue; - } - - const command = parseCommandXml(firstUserText); - if (command.kind === "local-command") { - continue; - } - - if ( - command.kind === "command" && - ignoreCommands.includes(command.commandName) - ) { - continue; - } - - firstCommand = command; - break; - } - - if (firstCommand !== null) { - firstCommandCache.set(jsonlFilePath, firstCommand); - } - - return firstCommand; -}; - -export const getSessionMeta = async ( - jsonlFilePath: string, -): Promise => { - const stats = statSync(jsonlFilePath); - const lastModifiedUnixTime = stats.ctime.getTime(); - - const content = await readFile(jsonlFilePath, "utf-8"); - const lines = content.split("\n"); - - const sessionMeta: SessionMeta = { - messageCount: lines.length, - firstCommand: getFirstCommand(jsonlFilePath, lines), - lastModifiedAt: lastModifiedUnixTime - ? new Date(lastModifiedUnixTime).toISOString() - : null, - }; - - return sessionMeta; -}; diff --git a/src/server/service/session/getSessions.ts b/src/server/service/session/getSessions.ts deleted file mode 100644 index 5927429..0000000 --- a/src/server/service/session/getSessions.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { readdir } from "node:fs/promises"; -import { basename, extname, resolve } from "node:path"; - -import { decodeProjectId } from "../project/id"; -import type { Session } from "../types"; -import { getSessionMeta } from "./getSessionMeta"; - -const getTime = (date: string | null) => { - if (date === null) return 0; - return new Date(date).getTime(); -}; - -export const getSessions = async ( - projectId: string, -): Promise<{ sessions: Session[] }> => { - const claudeProjectPath = decodeProjectId(projectId); - - try { - 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(claudeProjectPath, d.name); - - return { - id: basename(fullPath, extname(fullPath)), - jsonlFilePath: fullPath, - meta: await getSessionMeta(fullPath), - }; - }), - ); - - return { - sessions: sessions.sort((a, b) => { - return getTime(b.meta.lastModifiedAt) - getTime(a.meta.lastModifiedAt); - }), - }; - } catch (error) { - console.warn(`Failed to read sessions for project ${projectId}:`, error); - return { sessions: [] }; - } -}; diff --git a/src/server/service/session/id.ts b/src/server/service/session/id.ts new file mode 100644 index 0000000..203fdfe --- /dev/null +++ b/src/server/service/session/id.ts @@ -0,0 +1,11 @@ +import { basename, extname, resolve } from "node:path"; +import { decodeProjectId } from "../project/id"; + +export const encodeSessionId = (jsonlFilePath: string) => { + return basename(jsonlFilePath, extname(jsonlFilePath)); +}; + +export const decodeSessionId = (projectId: string, sessionId: string) => { + const projectPath = decodeProjectId(projectId); + return resolve(projectPath, `${sessionId}.jsonl`); +}; diff --git a/src/server/service/session/sessionMetaStorage.ts b/src/server/service/session/sessionMetaStorage.ts new file mode 100644 index 0000000..b1dc26a --- /dev/null +++ b/src/server/service/session/sessionMetaStorage.ts @@ -0,0 +1,131 @@ +import { statSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { FileCacheStorage } from "../../lib/storage/FileCacheStorage"; +import { + type ParsedCommand, + parseCommandXml, + parsedCommandSchema, +} from "../parseCommandXml"; +import { parseJsonl } from "../parseJsonl"; +import { sessionMetaSchema } from "../schema"; +import type { SessionMeta } from "../types"; +import { decodeSessionId } from "./id"; +import { InMemoryCacheStorage } from "../../lib/storage/InMemoryCacheStorage"; + +const ignoreCommands = [ + "/clear", + "/login", + "/logout", + "/exit", + "/mcp", + "/memory", +]; + +class SessionMetaStorage { + private firstCommandCache = FileCacheStorage.load( + "first-command-cache", + parsedCommandSchema + ); + private sessionMetaCache = new InMemoryCacheStorage(); + + public async getSessionMeta( + projectId: string, + sessionId: string + ): Promise { + const cached = this.sessionMetaCache.get(sessionId); + if (cached !== undefined) { + return cached; + } + + const sessionPath = decodeSessionId(projectId, sessionId); + + const stats = statSync(sessionPath); + const lastModifiedUnixTime = stats.mtime.getTime(); + + const content = await readFile(sessionPath, "utf-8"); + const lines = content.split("\n"); + + const sessionMeta: SessionMeta = { + messageCount: lines.length, + firstCommand: this.getFirstCommand(sessionPath, lines), + lastModifiedAt: lastModifiedUnixTime + ? new Date(lastModifiedUnixTime).toISOString() + : null, + }; + + this.sessionMetaCache.save(sessionId, sessionMeta); + + return sessionMeta; + } + + private getFirstCommand = ( + jsonlFilePath: string, + lines: string[] + ): ParsedCommand | null => { + const cached = this.firstCommandCache.get(jsonlFilePath); + if (cached !== undefined) { + return cached; + } + + let firstCommand: ParsedCommand | null = null; + + for (const line of lines) { + const conversation = parseJsonl(line).at(0); + + if (conversation === undefined || conversation.type !== "user") { + continue; + } + + const firstUserText = + conversation === null + ? null + : typeof conversation.message.content === "string" + ? conversation.message.content + : (() => { + const firstContent = conversation.message.content.at(0); + if (firstContent === undefined) return null; + if (typeof firstContent === "string") return firstContent; + if (firstContent.type === "text") return firstContent.text; + return null; + })(); + + if (firstUserText === null) { + continue; + } + + if ( + firstUserText === + "Caveat: The messages below were generated by the user while running local commands. DO NOT respond to these messages or otherwise consider them in your response unless the user explicitly asks you to." + ) { + continue; + } + + const command = parseCommandXml(firstUserText); + if (command.kind === "local-command") { + continue; + } + + if ( + command.kind === "command" && + ignoreCommands.includes(command.commandName) + ) { + continue; + } + + firstCommand = command; + break; + } + + if (firstCommand !== null) { + this.firstCommandCache.save(jsonlFilePath, firstCommand); + } + + return firstCommand; + }; + + public invalidateSession(_projectId: string, sessionId: string) { + this.sessionMetaCache.invalidate(sessionId); + } +} + +export const sessionMetaStorage = new SessionMetaStorage(); diff --git a/src/server/service/types.ts b/src/server/service/types.ts index 19c229d..b5ca5d9 100644 --- a/src/server/service/types.ts +++ b/src/server/service/types.ts @@ -1,5 +1,6 @@ +import type { z } from "zod"; import type { Conversation } from "../../lib/conversation-schema"; -import type { ParsedCommand } from "./parseCommandXml"; +import type { projectMetaSchema, sessionMetaSchema } from "./schema"; export type Project = { id: string; @@ -7,12 +8,7 @@ export type Project = { meta: ProjectMeta; }; -export type ProjectMeta = { - projectName: string | null; - projectPath: string | null; - lastModifiedAt: Date | null; - sessionCount: number; -}; +export type ProjectMeta = z.infer; export type Session = { id: string; @@ -20,11 +16,7 @@ export type Session = { meta: SessionMeta; }; -export type SessionMeta = { - messageCount: number; - firstCommand: ParsedCommand | null; - lastModifiedAt: string | null; -}; +export type SessionMeta = z.infer; export type ErrorJsonl = { type: "x-error";