import { createHash, randomUUID } from "node:crypto"; import { appendFile, mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; import type { Extension } from "../../../kernel"; import { ExtensionId, Hook } from "../../catalog"; export type MessageRole = "system" | "user" | "assistant"; export interface ChatMessage { role: MessageRole; content: string; createdAt: string; } export interface WorkspaceService { home: string; projectPath: string; workspaceId: string; conversationId: string; messages(): Promise; append(role: MessageRole, content: string): Promise; newConversation(): Promise; switchConversation(conversationId: string): Promise; } export interface WorkspaceOptions { home?: string; projectPath?: string; conversationId?: string; } export function createWorkspaceExtension(options: WorkspaceOptions = {}): Extension { const home = resolve( options.home ?? process.env.LLM_TO_AGENT_HOME ?? join(homedir(), ".llm-to-agent"), ); const projectPath = resolve(options.projectPath ?? process.cwd()); const workspaceId = createHash("sha256").update(projectPath).digest("hex").slice(0, 16); const workspaceDirectory = join(home, "workspaces", workspaceId); const conversationsDirectory = join(workspaceDirectory, "conversations"); const workspaceFile = join(workspaceDirectory, "workspace.json"); let conversationId = options.conversationId ?? "default"; async function saveWorkspace() { await writeFile( workspaceFile, `${JSON.stringify( { id: workspaceId, kind: "linked", projectPath, activeConversationId: conversationId, }, null, 2, )}\n`, "utf8", ); } const workspace: WorkspaceService = { home, projectPath, workspaceId, get conversationId() { return conversationId; }, async messages() { try { const content = await readFile( join(conversationsDirectory, conversationId, "messages.jsonl"), "utf8", ); return content .split("\n") .filter(Boolean) .map((line) => JSON.parse(line) as ChatMessage); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; } }, async append(role, content) { const message = { role, content, createdAt: new Date().toISOString(), }; await appendFile( join(conversationsDirectory, conversationId, "messages.jsonl"), `${JSON.stringify(message)}\n`, "utf8", ); return message; }, async newConversation() { const newConversationId = randomUUID(); await mkdir(join(conversationsDirectory, newConversationId), { recursive: true }); conversationId = newConversationId; await saveWorkspace(); return conversationId; }, async switchConversation(nextConversationId) { const conversations = await readdir(conversationsDirectory, { withFileTypes: true, }); const exists = conversations.some( (entry) => entry.isDirectory() && entry.name === nextConversationId, ); if (!exists) return false; conversationId = nextConversationId; await saveWorkspace(); return true; }, }; return { id: ExtensionId.Workspace, async setup(context) { await mkdir(workspaceDirectory, { recursive: true }); if (!options.conversationId) { try { const savedWorkspace = JSON.parse(await readFile(workspaceFile, "utf8")); if (savedWorkspace.activeConversationId) { conversationId = savedWorkspace.activeConversationId; } } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } } await mkdir(join(conversationsDirectory, conversationId), { recursive: true }); await saveWorkspace(); context.add(Hook.Workspace, workspace); }, }; }