perf: added cache for project, session

This commit is contained in:
d-kimsuon
2025-10-14 23:13:23 +09:00
parent a88ad89972
commit c7d89d47cd
24 changed files with 618 additions and 425 deletions

View File

@@ -2,7 +2,7 @@ import { handle } from "hono/vercel";
import { honoApp } from "../../../server/hono/app"; import { honoApp } from "../../../server/hono/app";
import { routes } from "../../../server/hono/route"; import { routes } from "../../../server/hono/route";
routes(honoApp); await routes(honoApp);
export const GET = handle(honoApp); export const GET = handle(honoApp);
export const POST = handle(honoApp); export const POST = handle(honoApp);

View File

@@ -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<void> => {
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
}
};

View File

@@ -9,8 +9,7 @@ import { env } from "../lib/env";
import { ClaudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController"; import { ClaudeCodeTaskController } from "../service/claude-code/ClaudeCodeTaskController";
import type { SerializableAliveTask } from "../service/claude-code/types"; import type { SerializableAliveTask } from "../service/claude-code/types";
import { adaptInternalEventToSSE } from "../service/events/adaptInternalEventToSSE"; import { adaptInternalEventToSSE } from "../service/events/adaptInternalEventToSSE";
import { getEventBus } from "../service/events/EventBus"; import { eventBus } from "../service/events/EventBus";
import { getFileWatcher } from "../service/events/fileWatcher";
import type { InternalEventDeclaration } from "../service/events/InternalEventDeclaration"; import type { InternalEventDeclaration } from "../service/events/InternalEventDeclaration";
import { writeTypeSafeSSE } from "../service/events/typeSafeSSE"; import { writeTypeSafeSSE } from "../service/events/typeSafeSSE";
import { getFileCompletion } from "../service/file-completion/getFileCompletion"; import { getFileCompletion } from "../service/file-completion/getFileCompletion";
@@ -19,14 +18,13 @@ import { getCommits } from "../service/git/getCommits";
import { getDiff } from "../service/git/getDiff"; import { getDiff } from "../service/git/getDiff";
import { getMcpList } from "../service/mcp/getMcpList"; import { getMcpList } from "../service/mcp/getMcpList";
import { claudeCommandsDirPath } from "../service/paths"; import { claudeCommandsDirPath } from "../service/paths";
import { getProject } from "../service/project/getProject"; import { ProjectRepository } from "../service/project/ProjectRepository";
import { getProjects } from "../service/project/getProjects"; import { SessionRepository } from "../service/session/SessionRepository";
import { getSession } from "../service/session/getSession";
import { getSessions } from "../service/session/getSessions";
import type { HonoAppType } from "./app"; import type { HonoAppType } from "./app";
import { initialize } from "./initialize";
import { configMiddleware } from "./middleware/config.middleware"; import { configMiddleware } from "./middleware/config.middleware";
export const routes = (app: HonoAppType) => { export const routes = async (app: HonoAppType) => {
let taskController: ClaudeCodeTaskController | null = null; let taskController: ClaudeCodeTaskController | null = null;
const getTaskController = (config: Config) => { const getTaskController = (config: Config) => {
if (!taskController) { if (!taskController) {
@@ -37,15 +35,14 @@ export const routes = (app: HonoAppType) => {
return taskController; return taskController;
}; };
const fileWatcher = getFileWatcher(); const sessionRepository = new SessionRepository();
const eventBus = getEventBus(); const projectRepository = new ProjectRepository();
if (env.get("NEXT_PHASE") !== "phase-production-build") { if (env.get("NEXT_PHASE") !== "phase-production-build") {
fileWatcher.startWatching(); await initialize({
sessionRepository,
setInterval(() => { projectRepository,
eventBus.emit("heartbeat", {}); });
}, 10 * 1000);
} }
return ( return (
@@ -71,7 +68,7 @@ export const routes = (app: HonoAppType) => {
}) })
.get("/projects", async (c) => { .get("/projects", async (c) => {
const { projects } = await getProjects(); const { projects } = await projectRepository.getProjects();
return c.json({ projects }); return c.json({ projects });
}) })
@@ -79,8 +76,8 @@ export const routes = (app: HonoAppType) => {
const { projectId } = c.req.param(); const { projectId } = c.req.param();
const [{ project }, { sessions }] = await Promise.all([ const [{ project }, { sessions }] = await Promise.all([
getProject(projectId), projectRepository.getProject(projectId),
getSessions(projectId).then(({ sessions }) => { sessionRepository.getSessions(projectId).then(({ sessions }) => {
let filteredSessions = sessions; let filteredSessions = sessions;
// Filter sessions based on hideNoUserMessageSession setting // Filter sessions based on hideNoUserMessageSession setting
@@ -157,7 +154,10 @@ export const routes = (app: HonoAppType) => {
.get("/projects/:projectId/sessions/:sessionId", async (c) => { .get("/projects/:projectId/sessions/:sessionId", async (c) => {
const { projectId, sessionId } = c.req.param(); const { projectId, sessionId } = c.req.param();
const { session } = await getSession(projectId, sessionId); const { session } = await sessionRepository.getSession(
projectId,
sessionId,
);
return c.json({ session }); return c.json({ session });
}) })
@@ -173,7 +173,7 @@ export const routes = (app: HonoAppType) => {
const { projectId } = c.req.param(); const { projectId } = c.req.param();
const { basePath } = c.req.valid("query"); const { basePath } = c.req.valid("query");
const { project } = await getProject(projectId); const { project } = await projectRepository.getProject(projectId);
if (project.meta.projectPath === null) { if (project.meta.projectPath === null) {
return c.json({ error: "Project path not found" }, 400); 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) => { .get("/projects/:projectId/claude-commands", async (c) => {
const { projectId } = c.req.param(); const { projectId } = c.req.param();
const { project } = await getProject(projectId); const { project } = await projectRepository.getProject(projectId);
const [globalCommands, projectCommands] = await Promise.allSettled([ const [globalCommands, projectCommands] = await Promise.allSettled([
readdir(claudeCommandsDirPath, { readdir(claudeCommandsDirPath, {
@@ -229,7 +229,7 @@ export const routes = (app: HonoAppType) => {
.get("/projects/:projectId/git/branches", async (c) => { .get("/projects/:projectId/git/branches", async (c) => {
const { projectId } = c.req.param(); const { projectId } = c.req.param();
const { project } = await getProject(projectId); const { project } = await projectRepository.getProject(projectId);
if (project.meta.projectPath === null) { if (project.meta.projectPath === null) {
return c.json({ error: "Project path not found" }, 400); 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) => { .get("/projects/:projectId/git/commits", async (c) => {
const { projectId } = c.req.param(); const { projectId } = c.req.param();
const { project } = await getProject(projectId); const { project } = await projectRepository.getProject(projectId);
if (project.meta.projectPath === null) { if (project.meta.projectPath === null) {
return c.json({ error: "Project path not found" }, 400); return c.json({ error: "Project path not found" }, 400);
@@ -279,7 +279,7 @@ export const routes = (app: HonoAppType) => {
async (c) => { async (c) => {
const { projectId } = c.req.param(); const { projectId } = c.req.param();
const { fromRef, toRef } = c.req.valid("json"); const { fromRef, toRef } = c.req.valid("json");
const { project } = await getProject(projectId); const { project } = await projectRepository.getProject(projectId);
if (project.meta.projectPath === null) { if (project.meta.projectPath === null) {
return c.json({ error: "Project path not found" }, 400); return c.json({ error: "Project path not found" }, 400);
@@ -318,7 +318,7 @@ export const routes = (app: HonoAppType) => {
async (c) => { async (c) => {
const { projectId } = c.req.param(); const { projectId } = c.req.param();
const { message } = c.req.valid("json"); const { message } = c.req.valid("json");
const { project } = await getProject(projectId); const { project } = await projectRepository.getProject(projectId);
if (project.meta.projectPath === null) { if (project.meta.projectPath === null) {
return c.json({ error: "Project path not found" }, 400); return c.json({ error: "Project path not found" }, 400);
@@ -353,7 +353,7 @@ export const routes = (app: HonoAppType) => {
async (c) => { async (c) => {
const { projectId, sessionId } = c.req.param(); const { projectId, sessionId } = c.req.param();
const { resumeMessage } = c.req.valid("json"); const { resumeMessage } = c.req.valid("json");
const { project } = await getProject(projectId); const { project } = await projectRepository.getProject(projectId);
if (project.meta.projectPath === null) { if (project.meta.projectPath === null) {
return c.json({ error: "Project path not found" }, 400); return c.json({ error: "Project path not found" }, 400);
@@ -473,4 +473,4 @@ export const routes = (app: HonoAppType) => {
); );
}; };
export type RouteType = ReturnType<typeof routes>; export type RouteType = Awaited<ReturnType<typeof routes>>;

View File

@@ -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<const T> {
private storage = new Map<string, T>();
private constructor(private readonly key: string) {}
public static load<const LoadSchema>(
key: string,
schema: z.ZodType<LoadSchema>
) {
const instance = new FileCacheStorage<LoadSchema>(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();
}
}

View File

@@ -0,0 +1,21 @@
export class InMemoryCacheStorage<const T> {
private storage = new Map<string, T>();
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);
}
}

View File

@@ -1,7 +1,7 @@
import prexit from "prexit"; import prexit from "prexit";
import { ulid } from "ulid"; import { ulid } from "ulid";
import type { Config } from "../../config/config"; import type { Config } from "../../config/config";
import { getEventBus, type IEventBus } from "../events/EventBus"; import { eventBus } from "../events/EventBus";
import { ClaudeCodeExecutor } from "./ClaudeCodeExecutor"; import { ClaudeCodeExecutor } from "./ClaudeCodeExecutor";
import { createMessageGenerator } from "./createMessageGenerator"; import { createMessageGenerator } from "./createMessageGenerator";
import type { import type {
@@ -16,14 +16,12 @@ import type {
export class ClaudeCodeTaskController { export class ClaudeCodeTaskController {
private claudeCode: ClaudeCodeExecutor; private claudeCode: ClaudeCodeExecutor;
private tasks: ClaudeCodeTask[] = []; private tasks: ClaudeCodeTask[] = [];
private eventBus: IEventBus;
private config: Config; private config: Config;
private pendingPermissionRequests: Map<string, PermissionRequest> = new Map(); private pendingPermissionRequests: Map<string, PermissionRequest> = new Map();
private permissionResponses: Map<string, PermissionResponse> = new Map(); private permissionResponses: Map<string, PermissionResponse> = new Map();
constructor(config: Config) { constructor(config: Config) {
this.claudeCode = new ClaudeCodeExecutor(); this.claudeCode = new ClaudeCodeExecutor();
this.eventBus = getEventBus();
this.config = config; this.config = config;
prexit(() => { prexit(() => {
@@ -85,7 +83,7 @@ export class ClaudeCodeTaskController {
); );
// Emit event to notify UI // Emit event to notify UI
this.eventBus.emit("permissionRequested", { eventBus.emit("permissionRequested", {
permissionRequest, permissionRequest,
}); });
@@ -389,7 +387,7 @@ export class ClaudeCodeTaskController {
} }
if (task.status === "paused" || task.status === "running") { if (task.status === "paused" || task.status === "running") {
this.eventBus.emit("taskChanged", { eventBus.emit("taskChanged", {
aliveTasks: this.aliveTasks, aliveTasks: this.aliveTasks,
changed: task, changed: task,
}); });

View File

@@ -36,12 +36,4 @@ class EventBus {
} }
} }
// singleton export const eventBus = new EventBus();
let eventBus: EventBus | null = null;
export const getEventBus = () => {
eventBus ??= new EventBus();
return eventBus;
};
export type IEventBus = ReturnType<typeof getEventBus>;

View File

@@ -1,5 +1,5 @@
import type { SSEStreamingApi } from "hono/streaming"; import type { SSEStreamingApi } from "hono/streaming";
import { getEventBus } from "./EventBus"; import { eventBus } from "./EventBus";
import type { InternalEventDeclaration } from "./InternalEventDeclaration"; import type { InternalEventDeclaration } from "./InternalEventDeclaration";
import { writeTypeSafeSSE } from "./typeSafeSSE"; import { writeTypeSafeSSE } from "./typeSafeSSE";
@@ -14,8 +14,6 @@ export const adaptInternalEventToSSE = (
console.log("SSE connection started"); console.log("SSE connection started");
const eventBus = getEventBus();
const stream = writeTypeSafeSSE(rawStream); const stream = writeTypeSafeSSE(rawStream);
const abortController = new AbortController(); const abortController = new AbortController();

View File

@@ -1,7 +1,7 @@
import { type FSWatcher, watch } from "node:fs"; import { type FSWatcher, watch } from "node:fs";
import z from "zod"; import z from "zod";
import { claudeProjectsDirPath } from "../paths"; import { claudeProjectsDirPath } from "../paths";
import { getEventBus, type IEventBus } from "./EventBus"; import { eventBus } from "./EventBus";
const fileRegExp = /(?<projectId>.*?)\/(?<sessionId>.*?)\.jsonl/; const fileRegExp = /(?<projectId>.*?)\/(?<sessionId>.*?)\.jsonl/;
const fileRegExpGroupSchema = z.object({ const fileRegExpGroupSchema = z.object({
@@ -13,11 +13,6 @@ export class FileWatcherService {
private isWatching = false; private isWatching = false;
private watcher: FSWatcher | null = null; private watcher: FSWatcher | null = null;
private projectWatchers: Map<string, FSWatcher> = new Map(); private projectWatchers: Map<string, FSWatcher> = new Map();
private eventBus: IEventBus;
constructor() {
this.eventBus = getEventBus();
}
public startWatching(): void { public startWatching(): void {
if (this.isWatching) return; if (this.isWatching) return;
@@ -42,13 +37,13 @@ export class FileWatcherService {
if (eventType === "change") { if (eventType === "change") {
// セッションファイルの中身が変更されている // セッションファイルの中身が変更されている
this.eventBus.emit("sessionChanged", { eventBus.emit("sessionChanged", {
projectId, projectId,
sessionId, sessionId,
}); });
} else if (eventType === "rename") { } else if (eventType === "rename") {
// セッションファイルの追加/削除 // セッションファイルの追加/削除
this.eventBus.emit("sessionListChanged", { eventBus.emit("sessionListChanged", {
projectId, projectId,
}); });
} else { } else {
@@ -75,13 +70,4 @@ export class FileWatcherService {
} }
} }
// シングルトンインスタンス export const fileWatcher = new FileWatcherService();
let watcherInstance: FileWatcherService | null = null;
export const getFileWatcher = (): FileWatcherService => {
if (!watcherInstance) {
console.log("Creating new FileWatcher instance");
watcherInstance = new FileWatcherService();
}
return watcherInstance;
};

View File

@@ -7,21 +7,24 @@ const matchSchema = z.object({
content: z.string(), content: z.string(),
}); });
export type ParsedCommand = export const parsedCommandSchema = z.union([
| { z.object({
kind: "command"; kind: z.literal("command"),
commandName: string; commandName: z.string(),
commandArgs?: string; commandArgs: z.string().optional(),
commandMessage?: string; commandMessage: z.string().optional(),
} }),
| { z.object({
kind: "local-command"; kind: z.literal("local-command"),
stdout: string; stdout: z.string(),
} }),
| { z.object({
kind: "text"; kind: z.literal("text"),
content: string; content: z.string(),
}; }),
]);
export type ParsedCommand = z.infer<typeof parsedCommandSchema>;
export const parseCommandXml = (content: string): ParsedCommand => { export const parseCommandXml = (content: string): ParsedCommand => {
const matches = Array.from(content.matchAll(regExp)) const matches = Array.from(content.matchAll(regExp))

View File

@@ -18,3 +18,9 @@ export const claudeCommandsDirPath = resolve(
globalClaudeDirectoryPath, globalClaudeDirectoryPath,
"commands", "commands",
); );
export const claudeCodeViewerCacheDirPath = resolve(
homedir(),
".claude-code-viewer",
"cache",
);

View File

@@ -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: [] };
}
}
}

View File

@@ -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,
},
};
};

View File

@@ -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<string, string | null>();
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<ProjectMeta> => {
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;
};

View File

@@ -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: [] };
}
};

View File

@@ -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<ProjectMeta>();
public async getProjectMeta(projectId: string): Promise<ProjectMeta> {
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<string | null> {
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();

View File

@@ -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(),
});

View File

@@ -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: [] };
}
}
}

View File

@@ -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,
};
};

View File

@@ -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<string, ParsedCommand | null>();
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<SessionMeta> => {
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;
};

View File

@@ -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<Session> => {
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: [] };
}
};

View File

@@ -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`);
};

View File

@@ -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<SessionMeta>();
public async getSessionMeta(
projectId: string,
sessionId: string
): Promise<SessionMeta> {
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();

View File

@@ -1,5 +1,6 @@
import type { z } from "zod";
import type { Conversation } from "../../lib/conversation-schema"; import type { Conversation } from "../../lib/conversation-schema";
import type { ParsedCommand } from "./parseCommandXml"; import type { projectMetaSchema, sessionMetaSchema } from "./schema";
export type Project = { export type Project = {
id: string; id: string;
@@ -7,12 +8,7 @@ export type Project = {
meta: ProjectMeta; meta: ProjectMeta;
}; };
export type ProjectMeta = { export type ProjectMeta = z.infer<typeof projectMetaSchema>;
projectName: string | null;
projectPath: string | null;
lastModifiedAt: Date | null;
sessionCount: number;
};
export type Session = { export type Session = {
id: string; id: string;
@@ -20,11 +16,7 @@ export type Session = {
meta: SessionMeta; meta: SessionMeta;
}; };
export type SessionMeta = { export type SessionMeta = z.infer<typeof sessionMetaSchema>;
messageCount: number;
firstCommand: ParsedCommand | null;
lastModifiedAt: string | null;
};
export type ErrorJsonl = { export type ErrorJsonl = {
type: "x-error"; type: "x-error";