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

@@ -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()

View File

@@ -0,0 +1,6 @@
import { z } from 'zod'
export const TextContentSchema = z.object({
type: z.literal('text'),
text: z.string(),
}).strict()

View File

@@ -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()

View File

@@ -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<typeof ToolResultContentSchema>

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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<typeof ConversationSchema>

View File

@@ -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<typeof AssistantMessageContentSchema>
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()

View File

@@ -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<typeof UserMessageContentSchema>
export const UserMessageSchema = z.object({
role: z.literal('user'),
content: z.union([
z.string(),
z.array(z.union([
z.string(),
UserMessageContentSchema
]))
]),
}).strict()

View File

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

View File

@@ -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()

View File

@@ -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()

View File

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

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

View File

@@ -2,7 +2,7 @@
"extends": "@tsconfig/strictest/tsconfig.json",
"compilerOptions": {
// transpile target
"target": "ES2017",
"target": "ES2020",
"lib": ["dom", "dom.iterable", "esnext"],
"noEmit": true,
// module