feat: implmenet core apis

This commit is contained in:
d-kimsuon
2025-08-30 01:51:17 +09:00
parent 301cb51940
commit 0d5ac6c66f
30 changed files with 706 additions and 15 deletions

View File

@@ -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<typeof routes>;

View File

@@ -0,0 +1,82 @@
import { z } from "zod";
const regExp = /<(?<tag>[^>]+)>(?<content>\s*[^<]*?\s*)<\/\k<tag>>/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,
};
}
};

View File

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

View File

@@ -0,0 +1,4 @@
import { homedir } from "node:os";
import { resolve } from "node:path";
export const claudeProjectPath = resolve(homedir(), ".claude", "projects");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, SessionMeta>();
export const getSessionMeta = async (
jsonlFilePath: string
): Promise<SessionMeta> => {
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;
};

View File

@@ -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<Session> => {
const fullPath = resolve(d.parentPath, d.name);
return {
id: d.name,
jsonlFilePath: fullPath,
meta: await getSessionMeta(fullPath),
};
})
);
return {
sessions,
};
};

View File

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