333 lines
10 KiB
TypeScript
333 lines
10 KiB
TypeScript
/**
|
|
* 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<string, Session>
|
|
messages: Record<string, SessionMessage>
|
|
}
|
|
|
|
/**
|
|
* Native session management for Binary-Free Mode
|
|
*/
|
|
export class NativeSessionManager {
|
|
private stores = new Map<string, SessionStore>()
|
|
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<void> {
|
|
const dir = path.join(this.dataDir, workspaceId)
|
|
if (!existsSync(dir)) {
|
|
await mkdir(dir, { recursive: true })
|
|
}
|
|
}
|
|
|
|
private async loadStore(workspaceId: string): Promise<SessionStore> {
|
|
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<void> {
|
|
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<Session[]> {
|
|
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<Session | null> {
|
|
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<Session> {
|
|
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<Session>): Promise<Session | null> {
|
|
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<boolean> {
|
|
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<Session> {
|
|
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<Session> {
|
|
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<SessionMessage[]> {
|
|
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<SessionMessage, "id" | "sessionId" | "createdAt" | "updatedAt">): Promise<SessionMessage> {
|
|
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<SessionMessage>): Promise<SessionMessage | null> {
|
|
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<void> {
|
|
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
|
|
}
|