/** * Session Store - Native session management without OpenCode binary * * This provides a complete replacement for OpenCode's session management, * allowing NomadArch to work in "Binary-Free Mode". */ import { readFile, writeFile, mkdir } from "fs/promises" import { existsSync } from "fs" import path from "path" import { ulid } from "ulid" import { createLogger } from "../logger" const log = createLogger({ component: "session-store" }) // Types matching OpenCode's schema for compatibility export interface SessionMessage { id: string sessionId: string role: "user" | "assistant" | "system" | "tool" content?: string parts?: MessagePart[] createdAt: number updatedAt: number toolCalls?: ToolCall[] toolCallId?: string status?: "pending" | "streaming" | "completed" | "error" } export interface MessagePart { type: "text" | "tool_call" | "tool_result" | "thinking" | "code" content?: string toolCall?: ToolCall toolResult?: ToolResult } export interface ToolCall { id: string type: "function" function: { name: string arguments: string } } export interface ToolResult { toolCallId: string content: string isError?: boolean } export interface Session { id: string workspaceId: string title?: string parentId?: string | null createdAt: number updatedAt: number messageIds: string[] model?: { providerId: string modelId: string } agent?: string revert?: { messageID: string reason?: string } | null } export interface SessionStore { sessions: Record messages: Record } /** * Native session management for Binary-Free Mode */ export class NativeSessionManager { private stores = new Map() private dataDir: string constructor(dataDir: string) { this.dataDir = dataDir } private getStorePath(workspaceId: string): string { return path.join(this.dataDir, workspaceId, "sessions.json") } private async ensureDir(workspaceId: string): Promise { const dir = path.join(this.dataDir, workspaceId) if (!existsSync(dir)) { await mkdir(dir, { recursive: true }) } } private async loadStore(workspaceId: string): Promise { if (this.stores.has(workspaceId)) { return this.stores.get(workspaceId)! } const storePath = this.getStorePath(workspaceId) let store: SessionStore = { sessions: {}, messages: {} } if (existsSync(storePath)) { try { const data = await readFile(storePath, "utf-8") store = JSON.parse(data) } catch (error) { log.error({ workspaceId, error }, "Failed to load session store") } } this.stores.set(workspaceId, store) return store } private async saveStore(workspaceId: string): Promise { const store = this.stores.get(workspaceId) if (!store) return await this.ensureDir(workspaceId) const storePath = this.getStorePath(workspaceId) await writeFile(storePath, JSON.stringify(store, null, 2), "utf-8") } // Session CRUD operations async listSessions(workspaceId: string): Promise { const store = await this.loadStore(workspaceId) return Object.values(store.sessions).sort((a, b) => b.updatedAt - a.updatedAt) } async getSession(workspaceId: string, sessionId: string): Promise { const store = await this.loadStore(workspaceId) return store.sessions[sessionId] ?? null } async createSession(workspaceId: string, options?: { title?: string parentId?: string model?: { providerId: string; modelId: string } agent?: string }): Promise { const store = await this.loadStore(workspaceId) const now = Date.now() const session: Session = { id: ulid(), workspaceId, title: options?.title ?? "New Session", parentId: options?.parentId ?? null, createdAt: now, updatedAt: now, messageIds: [], model: options?.model, agent: options?.agent, } store.sessions[session.id] = session await this.saveStore(workspaceId) log.info({ workspaceId, sessionId: session.id }, "Created new session") return session } async updateSession(workspaceId: string, sessionId: string, updates: Partial): Promise { const store = await this.loadStore(workspaceId) const session = store.sessions[sessionId] if (!session) return null const updated = { ...session, ...updates, id: session.id, // Prevent ID change workspaceId: session.workspaceId, // Prevent workspace change updatedAt: Date.now(), } store.sessions[sessionId] = updated await this.saveStore(workspaceId) return updated } async deleteSession(workspaceId: string, sessionId: string): Promise { const store = await this.loadStore(workspaceId) const session = store.sessions[sessionId] if (!session) return false // Delete all messages in the session for (const messageId of session.messageIds) { delete store.messages[messageId] } delete store.sessions[sessionId] await this.saveStore(workspaceId) log.info({ workspaceId, sessionId }, "Deleted session") return true } async forkSession(workspaceId: string, sessionId: string): Promise { const store = await this.loadStore(workspaceId) const original = store.sessions[sessionId] if (!original) throw new Error(`Session not found: ${sessionId}`) const now = Date.now() const forked: Session = { ...original, id: ulid(), title: original.title ? `${original.title} (fork)` : "Forked Session", parentId: original.parentId || original.id, createdAt: now, updatedAt: now, messageIds: [...original.messageIds], // Shallow copy of message IDs } store.sessions[forked.id] = forked await this.saveStore(workspaceId) return forked } async revert(workspaceId: string, sessionId: string, messageId?: string): Promise { const store = await this.loadStore(workspaceId) const session = store.sessions[sessionId] if (!session) throw new Error(`Session not found: ${sessionId}`) if (!messageId) { // Revert last message if (session.messageIds.length > 0) { const lastId = session.messageIds.pop() if (lastId) delete store.messages[lastId] } } else { // Revert to specific message const index = session.messageIds.indexOf(messageId) if (index !== -1) { const toDelete = session.messageIds.splice(index + 1) for (const id of toDelete) { delete store.messages[id] } } } session.updatedAt = Date.now() await this.saveStore(workspaceId) return session } // Message operations async getSessionMessages(workspaceId: string, sessionId: string): Promise { const store = await this.loadStore(workspaceId) const session = store.sessions[sessionId] if (!session) return [] return session.messageIds .map(id => store.messages[id]) .filter((msg): msg is SessionMessage => msg !== undefined) } async addMessage(workspaceId: string, sessionId: string, message: Omit): Promise { const store = await this.loadStore(workspaceId) const session = store.sessions[sessionId] if (!session) throw new Error(`Session not found: ${sessionId}`) const now = Date.now() const newMessage: SessionMessage = { ...message, id: ulid(), sessionId, createdAt: now, updatedAt: now, } store.messages[newMessage.id] = newMessage session.messageIds.push(newMessage.id) session.updatedAt = now await this.saveStore(workspaceId) return newMessage } async updateMessage(workspaceId: string, messageId: string, updates: Partial): Promise { const store = await this.loadStore(workspaceId) const message = store.messages[messageId] if (!message) return null const updated = { ...message, ...updates, id: message.id, // Prevent ID change sessionId: message.sessionId, // Prevent session change updatedAt: Date.now(), } store.messages[messageId] = updated await this.saveStore(workspaceId) return updated } // Utility async clearWorkspace(workspaceId: string): Promise { this.stores.delete(workspaceId) // Optionally delete file } getActiveSessionCount(workspaceId: string): number { const store = this.stores.get(workspaceId) return store ? Object.keys(store.sessions).length : 0 } } // Singleton instance let sessionManager: NativeSessionManager | null = null export function getSessionManager(dataDir?: string): NativeSessionManager { if (!sessionManager) { if (!dataDir) { throw new Error("Session manager not initialized - provide dataDir") } sessionManager = new NativeSessionManager(dataDir) } return sessionManager } export function initSessionManager(dataDir: string): NativeSessionManager { sessionManager = new NativeSessionManager(dataDir) return sessionManager }