v0.5.0: Binary-Free Mode - No OpenCode binary required
✨ Major Features: - Native session management without OpenCode binary - Provider routing: OpenCode Zen (free), Qwen OAuth, Z.AI - Streaming chat with tool execution loop - Mode detection API (/api/meta/mode) - MCP integration fix (resolved infinite loading) - NomadArch Native option in UI with comparison info 🆓 Free Models (No API Key): - GPT-5 Nano (400K context) - Grok Code Fast 1 (256K context) - GLM-4.7 (205K context) - Doubao Seed Code (256K context) - Big Pickle (200K context) 📦 New Files: - session-store.ts: Native session persistence - native-sessions.ts: REST API for sessions - lite-mode.ts: UI mode detection client - native-sessions.ts (UI): SolidJS store 🔧 Updated: - All installers: Optional binary download - All launchers: Mode detection display - Binary selector: Added NomadArch Native option - README: Binary-Free Mode documentation
This commit is contained in:
284
packages/server/src/storage/session-store.ts
Normal file
284
packages/server/src/storage/session-store.ts
Normal file
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user