import crypto from "crypto" import fs from "fs" import path from "path" import os from "os" export interface Message { role: "system" | "user" | "assistant" | "tool" content: string toolCalls?: ToolCallMessage[] toolCallId?: string } export interface ToolCallMessage { id: string type: "function" function: { name: string arguments: string } } export interface ConversationMeta { id: string createdAt: string updatedAt: string model: string messageCount: number totalTokens?: number } export class Conversation { messages: Message[] = [] id: string model: string createdAt: Date totalTokens = 0 constructor(model: string) { this.id = crypto.randomUUID() this.model = model this.createdAt = new Date() } addSystemMessage(content: string): void { this.messages.push({ role: "system", content }) } addUserMessage(content: string): void { this.messages.push({ role: "user", content }) } addAssistantMessage(content: string, toolCalls?: ToolCallMessage[]): void { this.messages.push({ role: "assistant", content, toolCalls }) } addToolResult(toolCallId: string, content: string | Record, isError?: boolean): void { const text = typeof content === "string" ? content : JSON.stringify(content) this.messages.push({ role: "tool", content: isError ? `Error: ${text}` : text, toolCallId, }) } clear(): void { this.messages = [] } getApiMessages(): Message[] { return [...this.messages] } getTokenEstimate(): number { return this.messages.reduce((total, msg) => { const content = typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content) return total + Math.ceil(content.length / 4) }, 0) } getMeta(): ConversationMeta { return { id: this.id, createdAt: this.createdAt.toISOString(), updatedAt: new Date().toISOString(), model: this.model, messageCount: this.messages.length, totalTokens: this.totalTokens || undefined, } } save(sessionsDir?: string): string { const dir = sessionsDir || path.join(os.homedir(), ".llmtune", "sessions") fs.mkdirSync(dir, { recursive: true }) const filePath = path.join(dir, `${this.id}.json`) const data = { meta: this.getMeta(), messages: this.messages, } fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8") return filePath } static load(filePath: string): Conversation { const raw = fs.readFileSync(filePath, "utf-8") const data = JSON.parse(raw) as { meta: ConversationMeta messages: Message[] } const conv = new Conversation(data.meta.model) conv.id = data.meta.id conv.createdAt = new Date(data.meta.createdAt) conv.messages = data.messages conv.totalTokens = data.meta.totalTokens || 0 return conv } static listSessions(sessionsDir?: string): ConversationMeta[] { const dir = sessionsDir || path.join(os.homedir(), ".llmtune", "sessions") if (!fs.existsSync(dir)) return [] return fs .readdirSync(dir) .filter((f) => f.endsWith(".json")) .map((f) => { try { const raw = fs.readFileSync(path.join(dir, f), "utf-8") return (JSON.parse(raw) as { meta: ConversationMeta }).meta } catch { return null } }) .filter((m): m is ConversationMeta => m !== null) .sort( (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime() ) } }