UNPKG

@stackmemoryai/stackmemory

Version:

Lossless, project-scoped memory for AI coding tools. Durable context across sessions with 56 MCP tools, FTS5 search, conductor orchestrator, loop/watch monitoring, snapshot capture, pre-flight overlap checks, Claude/Codex/OpenCode wrappers, Linear sync, a

347 lines (346 loc) 10.5 kB
import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __pathDirname } from 'path'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __pathDirname(__filename); import { v4 as uuidv4 } from "uuid"; import * as fs from "fs/promises"; import * as path from "path"; import { logger } from "../monitoring/logger.js"; import { SystemError, ErrorCode } from "../errors/index.js"; function _getEnv(key, defaultValue) { const value = process.env[key]; if (value === void 0) { if (defaultValue !== void 0) return defaultValue; throw new Error(`Environment variable ${key} is required`); } return value; } function _getOptionalEnv(key) { return process.env[key]; } var FrameQueryMode = /* @__PURE__ */ ((FrameQueryMode2) => { FrameQueryMode2["CURRENT_SESSION"] = "current"; FrameQueryMode2["PROJECT_ACTIVE"] = "project"; FrameQueryMode2["ALL_ACTIVE"] = "all"; FrameQueryMode2["HISTORICAL"] = "historical"; return FrameQueryMode2; })(FrameQueryMode || {}); class SessionManager { static instance; sessionsDir; currentSession = null; STALE_THRESHOLD = 24 * 60 * 60 * 1e3; // 24 hours constructor() { const homeDir = process.env["HOME"] || process.env["USERPROFILE"] || ""; this.sessionsDir = path.join(homeDir, ".stackmemory", "sessions"); } static getInstance() { if (!SessionManager.instance) { SessionManager.instance = new SessionManager(); } return SessionManager.instance; } async initialize() { try { await fs.mkdir(this.sessionsDir, { recursive: true }); await fs.mkdir(path.join(this.sessionsDir, "projects"), { recursive: true }); await fs.mkdir(path.join(this.sessionsDir, "history"), { recursive: true }); } catch (error) { throw new SystemError( "Failed to initialize session directories", ErrorCode.INITIALIZATION_ERROR, { error, sessionsDir: this.sessionsDir } ); } } async getOrCreateSession(options) { if (options?.sessionId) { const session = await this.loadSession(options.sessionId); if (session) { this.currentSession = session; return session; } } const envSessionId = process.env["STACKMEMORY_SESSION"]; if (envSessionId) { const session = await this.loadSession(envSessionId); if (session) { this.currentSession = session; return session; } } const projectHash = await this.getProjectHash(options?.projectPath); const branch = options?.branch || await this.getGitBranch(options?.projectPath); if (projectHash) { const branchSession = await this.findProjectBranchSession( projectHash, branch ); if (branchSession && this.isSessionRecent(branchSession)) { await this.touchSession(branchSession); this.currentSession = branchSession; return branchSession; } const lastActive = await this.findLastActiveSession(projectHash); if (lastActive && this.isSessionRecent(lastActive)) { await this.touchSession(lastActive); this.currentSession = lastActive; return lastActive; } } const newSession = await this.createSession({ projectId: projectHash || "global", branch, metadata: options?.metadata }); this.currentSession = newSession; return newSession; } async createSession(params) { const session = { sessionId: uuidv4(), runId: uuidv4(), projectId: params.projectId, branch: params.branch, startedAt: Date.now(), lastActiveAt: Date.now(), metadata: { ...params.metadata, user: process.env["USER"], environment: process.env["NODE_ENV"] || "development", cliVersion: process.env["npm_package_version"] }, state: "active" }; await this.saveSession(session); await this.setProjectActiveSession(params.projectId, session.sessionId); this.currentSession = session; logger.info("Created new session", { sessionId: session.sessionId, projectId: session.projectId, branch: session.branch }); return session; } async loadSession(sessionId) { try { const sessionPath = path.join(this.sessionsDir, `${sessionId}.json`); const data = await fs.readFile(sessionPath, "utf-8"); return JSON.parse(data); } catch { try { const historyPath = path.join( this.sessionsDir, "history", `${sessionId}.json` ); const data = await fs.readFile(historyPath, "utf-8"); return JSON.parse(data); } catch { return null; } } } async saveSession(session) { const sessionPath = path.join( this.sessionsDir, `${session.sessionId}.json` ); await fs.writeFile(sessionPath, JSON.stringify(session, null, 2)); } async suspendSession(sessionId) { const id = sessionId || this.currentSession?.sessionId; if (!id) return; const session = await this.loadSession(id); if (session) { session.state = "suspended"; session.lastActiveAt = Date.now(); await this.saveSession(session); } } async resumeSession(sessionId) { const session = await this.loadSession(sessionId); if (!session) { throw new SystemError("Session not found", ErrorCode.NOT_FOUND, { sessionId }); } session.state = "active"; session.lastActiveAt = Date.now(); await this.saveSession(session); this.currentSession = session; return session; } async closeSession(sessionId) { const id = sessionId || this.currentSession?.sessionId; if (!id) return; const session = await this.loadSession(id); if (session) { session.state = "closed"; session.lastActiveAt = Date.now(); const sessionPath = path.join( this.sessionsDir, `${session.sessionId}.json` ); const historyPath = path.join( this.sessionsDir, "history", `${session.sessionId}.json` ); await fs.rename(sessionPath, historyPath); } } async listSessions(filter) { const sessions = []; const files = await fs.readdir(this.sessionsDir); for (const file of files) { if (file.endsWith(".json")) { const session = await this.loadSession(file.replace(".json", "")); if (session) { sessions.push(session); } } } return sessions.filter((s) => { if (filter?.projectId && s.projectId !== filter.projectId) return false; if (filter?.state && s.state !== filter.state) return false; if (filter?.branch && s.branch !== filter.branch) return false; return true; }); } async mergeSessions(sourceId, targetId) { const source = await this.loadSession(sourceId); const target = await this.loadSession(targetId); if (!source || !target) { throw new SystemError( "Session not found for merge", ErrorCode.NOT_FOUND, { sourceId, targetId } ); } target.metadata = { ...target.metadata, ...source.metadata, tags: [...target.metadata.tags || [], ...source.metadata.tags || []] }; target.lastActiveAt = Date.now(); await this.closeSession(sourceId); await this.saveSession(target); logger.info("Merged sessions", { source: sourceId, target: targetId }); return target; } async cleanupStaleSessions(maxAge = 30 * 24 * 60 * 60 * 1e3) { const historyDir = path.join(this.sessionsDir, "history"); const files = await fs.readdir(historyDir); const cutoff = Date.now() - maxAge; let cleaned = 0; for (const file of files) { if (file.endsWith(".json")) { const filePath = path.join(historyDir, file); const stats = await fs.stat(filePath); if (stats.mtimeMs < cutoff) { await fs.unlink(filePath); cleaned++; } } } logger.info(`Cleaned up ${cleaned} stale sessions`); return cleaned; } getCurrentSession() { return this.currentSession; } getSessionRunId() { return this.currentSession?.runId || uuidv4(); } async getProjectHash(projectPath) { try { const cwd = projectPath || process.cwd(); const _pathModule = await import("path"); let identifier; try { const { execSync } = await import("child_process"); identifier = execSync("git config --get remote.origin.url", { cwd, encoding: "utf-8", timeout: 5e3 }).trim(); } catch { identifier = cwd; } const cleaned = identifier.replace(/\.git$/, "").replace(/[^a-zA-Z0-9-]/g, "-").toLowerCase(); return cleaned.substring(cleaned.length - 50); } catch { return null; } } async getGitBranch(projectPath) { try { const { execSync } = await import("child_process"); const cwd = projectPath || process.cwd(); const branch = execSync("git rev-parse --abbrev-ref HEAD", { cwd, encoding: "utf-8" }).trim(); return branch; } catch { return void 0; } } async findProjectBranchSession(projectHash, branch) { if (!branch) return null; const sessions = await this.listSessions({ projectId: projectHash, state: "active", branch }); return sessions.sort((a, b) => b.lastActiveAt - a.lastActiveAt)[0] || null; } async findLastActiveSession(projectHash) { const sessions = await this.listSessions({ projectId: projectHash, state: "active" }); return sessions.sort((a, b) => b.lastActiveAt - a.lastActiveAt)[0] || null; } async setProjectActiveSession(projectId, sessionId) { const projectFile = path.join( this.sessionsDir, "projects", `${projectId}.json` ); await fs.writeFile( projectFile, JSON.stringify( { projectId, activeSessionId: sessionId, updatedAt: Date.now() }, null, 2 ) ); } isSessionRecent(session) { return Date.now() - session.lastActiveAt < this.STALE_THRESHOLD; } async touchSession(session) { session.lastActiveAt = Date.now(); await this.saveSession(session); } } const sessionManager = SessionManager.getInstance(); export { FrameQueryMode, SessionManager, sessionManager };