UNPKG

arela

Version:

AI-powered CTO with multi-agent orchestration, code summarization, visual testing (web + mobile) for blazing fast development.

393 lines (390 loc) 12.1 kB
import path from "node:path"; import fs from "fs-extra"; import Database from "better-sqlite3"; import { randomUUID } from "node:crypto"; /** * Session Memory Layer (Layer 4 - Hexi-Memory) * * Stores short-term context for the current conversation/task: * - Current task being worked on * - Open files being tracked * - Conversation history * - Active ticket * - Arbitrary context key-value pairs * * Features: * - In-memory cache for fast access (<50ms) * - SQLite persistence for crash recovery * - Auto-snapshot every 30 seconds * - Restore from last snapshot on init */ export class SessionMemory { cwd; db; dbPath; cache; snapshotInterval; initialized = false; exitHandler; sigintHandler; sigtermHandler; constructor(cwd = process.cwd()) { this.cwd = cwd; this.dbPath = path.join(cwd, ".arela", "memory", "session.db"); // Initialize in-memory cache this.cache = { sessionId: randomUUID(), startTime: Date.now(), filesOpen: [], conversationHistory: [], context: {}, }; } /** * Initialize session memory * - Sets up SQLite database * - Attempts to restore from last snapshot * - Starts auto-snapshot interval */ async init() { if (this.initialized) { return; } // Ensure directory exists await fs.ensureDir(path.dirname(this.dbPath)); // Open database this.db = new Database(this.dbPath); this.db.pragma("journal_mode = WAL"); // Better concurrency // Create tables if they don't exist this.createTables(); // Try to restore from last snapshot await this.restore(); // Start auto-snapshot (every 30 seconds) this.snapshotInterval = setInterval(() => { this.snapshot().catch(console.error); }, 30000); // Save on exit - store handlers for cleanup this.exitHandler = () => { if (this.initialized) { this.snapshot().catch(console.error); } }; this.sigintHandler = () => { if (this.initialized) { this.snapshot().catch(console.error); this.close(); } process.exit(0); }; this.sigtermHandler = () => { if (this.initialized) { this.snapshot().catch(console.error); this.close(); } process.exit(0); }; process.on("exit", this.exitHandler); process.on("SIGINT", this.sigintHandler); process.on("SIGTERM", this.sigtermHandler); this.initialized = true; } /** * Create database tables */ createTables() { if (!this.db) { throw new Error("Database not initialized"); } this.db.exec(` CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, start_time INTEGER NOT NULL, current_task TEXT, active_ticket TEXT, created_at INTEGER DEFAULT (strftime('%s', 'now')) ); CREATE TABLE IF NOT EXISTS session_files ( session_id TEXT NOT NULL, file_path TEXT NOT NULL, opened_at INTEGER DEFAULT (strftime('%s', 'now')), FOREIGN KEY (session_id) REFERENCES sessions(id) ); CREATE TABLE IF NOT EXISTS session_messages ( session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, timestamp INTEGER DEFAULT (strftime('%s', 'now')), FOREIGN KEY (session_id) REFERENCES sessions(id) ); CREATE TABLE IF NOT EXISTS session_context ( session_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, FOREIGN KEY (session_id) REFERENCES sessions(id), PRIMARY KEY (session_id, key) ); `); } /** * Get current session ID */ getSessionId() { return this.cache.sessionId; } /** * Get current task */ async getCurrentTask() { return this.cache.currentTask; } /** * Set current task */ async setCurrentTask(task) { this.cache.currentTask = task; } /** * Add message to conversation history */ async addMessage(message) { const messageWithTimestamp = { ...message, timestamp: message.timestamp || Date.now(), }; this.cache.conversationHistory.push(messageWithTimestamp); } /** * Get recent messages from conversation history */ async getRecentMessages(count) { return this.cache.conversationHistory.slice(-count); } /** * Get all messages from conversation history */ async getAllMessages() { return [...this.cache.conversationHistory]; } /** * Track an open file */ async trackOpenFile(filePath) { if (!this.cache.filesOpen.includes(filePath)) { this.cache.filesOpen.push(filePath); } } /** * Untrack a file */ async untrackFile(filePath) { this.cache.filesOpen = this.cache.filesOpen.filter(f => f !== filePath); } /** * Get list of open files */ async getOpenFiles() { return [...this.cache.filesOpen]; } /** * Set active ticket */ async setActiveTicket(ticketId) { this.cache.activeTicket = ticketId; } /** * Get active ticket */ async getActiveTicket() { return this.cache.activeTicket; } /** * Set context value */ async setContext(key, value) { this.cache.context[key] = value; } /** * Get context value */ async getContext(key) { return this.cache.context[key]; } /** * Get all context */ async getAllContext() { return { ...this.cache.context }; } /** * Delete context key */ async deleteContext(key) { delete this.cache.context[key]; } /** * Save current state to SQLite (snapshot) */ async snapshot() { if (!this.db) { throw new Error("Database not initialized. Call init() first."); } const tx = this.db.transaction(() => { // Clear existing session data this.db.prepare("DELETE FROM session_context WHERE session_id = ?").run(this.cache.sessionId); this.db.prepare("DELETE FROM session_messages WHERE session_id = ?").run(this.cache.sessionId); this.db.prepare("DELETE FROM session_files WHERE session_id = ?").run(this.cache.sessionId); this.db.prepare("DELETE FROM sessions WHERE id = ?").run(this.cache.sessionId); // Insert session this.db.prepare(` INSERT INTO sessions (id, start_time, current_task, active_ticket) VALUES (?, ?, ?, ?) `).run(this.cache.sessionId, this.cache.startTime, this.cache.currentTask || null, this.cache.activeTicket || null); // Insert files const insertFile = this.db.prepare(` INSERT INTO session_files (session_id, file_path) VALUES (?, ?) `); for (const filePath of this.cache.filesOpen) { insertFile.run(this.cache.sessionId, filePath); } // Insert messages const insertMessage = this.db.prepare(` INSERT INTO session_messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?) `); for (const message of this.cache.conversationHistory) { insertMessage.run(this.cache.sessionId, message.role, message.content, message.timestamp || Date.now()); } // Insert context const insertContext = this.db.prepare(` INSERT INTO session_context (session_id, key, value) VALUES (?, ?, ?) `); for (const [key, value] of Object.entries(this.cache.context)) { insertContext.run(this.cache.sessionId, key, JSON.stringify(value)); } }); tx(); } /** * Restore state from SQLite snapshot */ async restore() { if (!this.db) { throw new Error("Database not initialized. Call init() first."); } // Get most recent session const session = this.db.prepare(` SELECT id, start_time, current_task, active_ticket FROM sessions ORDER BY created_at DESC LIMIT 1 `).get(); if (!session) { // No previous session to restore return; } // Restore session data this.cache.sessionId = session.id; this.cache.startTime = session.start_time; this.cache.currentTask = session.current_task || undefined; this.cache.activeTicket = session.active_ticket || undefined; // Restore files const files = this.db.prepare(` SELECT file_path FROM session_files WHERE session_id = ? ORDER BY opened_at `).all(session.id); this.cache.filesOpen = files.map(f => f.file_path); // Restore messages const messages = this.db.prepare(` SELECT role, content, timestamp FROM session_messages WHERE session_id = ? ORDER BY timestamp `).all(session.id); this.cache.conversationHistory = messages.map(m => ({ role: m.role, content: m.content, timestamp: m.timestamp, })); // Restore context const contextRows = this.db.prepare(` SELECT key, value FROM session_context WHERE session_id = ? `).all(session.id); this.cache.context = {}; for (const row of contextRows) { try { this.cache.context[row.key] = JSON.parse(row.value); } catch { this.cache.context[row.key] = row.value; } } } /** * Clear current session (start fresh) */ async clear() { if (!this.db) { throw new Error("Database not initialized. Call init() first."); } // Delete from database this.db.prepare("DELETE FROM session_context WHERE session_id = ?").run(this.cache.sessionId); this.db.prepare("DELETE FROM session_messages WHERE session_id = ?").run(this.cache.sessionId); this.db.prepare("DELETE FROM session_files WHERE session_id = ?").run(this.cache.sessionId); this.db.prepare("DELETE FROM sessions WHERE id = ?").run(this.cache.sessionId); // Reset cache this.cache = { sessionId: randomUUID(), startTime: Date.now(), filesOpen: [], conversationHistory: [], context: {}, }; } /** * Get session statistics */ async getStats() { return { sessionId: this.cache.sessionId, startTime: this.cache.startTime, messagesCount: this.cache.conversationHistory.length, filesOpenCount: this.cache.filesOpen.length, contextKeysCount: Object.keys(this.cache.context).length, dbPath: this.dbPath, }; } /** * Close database and stop auto-snapshot */ close() { if (this.snapshotInterval) { clearInterval(this.snapshotInterval); this.snapshotInterval = undefined; } // Remove event listeners if (this.exitHandler) { process.off("exit", this.exitHandler); this.exitHandler = undefined; } if (this.sigintHandler) { process.off("SIGINT", this.sigintHandler); this.sigintHandler = undefined; } if (this.sigtermHandler) { process.off("SIGTERM", this.sigtermHandler); this.sigtermHandler = undefined; } if (this.db) { this.db.close(); this.db = undefined; } this.initialized = false; } } //# sourceMappingURL=session.js.map