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

877 lines (876 loc) 28.6 kB
import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __pathDirname } from 'path'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __pathDirname(__filename); import { FrameManager } from "./index.js"; import { SQLiteAdapter } from "../database/sqlite-adapter.js"; import { logger } from "../monitoring/logger.js"; import { ValidationError, DatabaseError, ErrorCode } from "../errors/index.js"; import { validateInput, CreateSharedStackSchema, SwitchStackSchema } from "./validation.js"; import { PermissionManager } from "./permission-manager.js"; class DualStackManager { adapter; individualStack; sharedStacks = /* @__PURE__ */ new Map(); activeContext; handoffRequests = /* @__PURE__ */ new Map(); permissionManager; constructor(adapter, projectId, userId, _defaultTeamId) { this.adapter = adapter; this.permissionManager = new PermissionManager(); const rawDb = adapter instanceof SQLiteAdapter ? adapter.getRawDatabase() : null; if (!rawDb) { throw new DatabaseError( "DualStackManager requires SQLiteAdapter with connected database", ErrorCode.DB_CONNECTION_FAILED, { adapter: adapter.constructor.name } ); } this.individualStack = new FrameManager(rawDb, projectId, userId); this.activeContext = { stackId: `individual-${userId}`, type: "individual", projectId, ownerId: userId, permissions: this.getDefaultIndividualPermissions(), metadata: {}, createdAt: /* @__PURE__ */ new Date(), lastActive: /* @__PURE__ */ new Date() }; this.permissionManager.setStackPermissions( userId, `individual-${userId}`, this.getDefaultIndividualPermissions() ); this.initializeSchema(); } async initializeSchema() { try { await this.adapter.beginTransaction(); const createStackContextsTable = ` CREATE TABLE IF NOT EXISTS stack_contexts ( stack_id TEXT PRIMARY KEY, type TEXT NOT NULL CHECK (type IN ('individual', 'shared')), project_id TEXT NOT NULL, owner_id TEXT, team_id TEXT, permissions TEXT NOT NULL, metadata TEXT DEFAULT '{}', created_at INTEGER NOT NULL, last_active INTEGER NOT NULL, CONSTRAINT valid_ownership CHECK ( (type = 'individual' AND owner_id IS NOT NULL AND team_id IS NULL) OR (type = 'shared' AND team_id IS NOT NULL) ) ) `; const createHandoffRequestsTable = ` CREATE TABLE IF NOT EXISTS handoff_requests ( request_id TEXT PRIMARY KEY, source_stack_id TEXT NOT NULL, target_stack_id TEXT NOT NULL, frame_ids TEXT NOT NULL, requester_id TEXT NOT NULL, target_user_id TEXT, message TEXT, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected', 'expired')), created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, FOREIGN KEY (source_stack_id) REFERENCES stack_contexts(stack_id), FOREIGN KEY (target_stack_id) REFERENCES stack_contexts(stack_id) ) `; const createStackSyncLogTable = ` CREATE TABLE IF NOT EXISTS stack_sync_log ( sync_id TEXT PRIMARY KEY, source_stack_id TEXT NOT NULL, target_stack_id TEXT NOT NULL, operation TEXT NOT NULL CHECK (operation IN ('handoff', 'merge', 'sync')), frame_count INTEGER NOT NULL, conflicts TEXT DEFAULT '[]', resolution TEXT, timestamp INTEGER NOT NULL, FOREIGN KEY (source_stack_id) REFERENCES stack_contexts(stack_id), FOREIGN KEY (target_stack_id) REFERENCES stack_contexts(stack_id) ) `; if (this.adapter.isConnected()) { const adapterAny = this.adapter; if (!await adapterAny.execute?.(createStackContextsTable)) { this.executeSchemaQuery(createStackContextsTable); } if (!await adapterAny.execute?.(createHandoffRequestsTable)) { this.executeSchemaQuery(createHandoffRequestsTable); } if (!await adapterAny.execute?.(createStackSyncLogTable)) { this.executeSchemaQuery(createStackSyncLogTable); } } await this.adapter.commitTransaction(); logger.info("Dual stack schema initialized successfully"); } catch (error) { await this.adapter.rollbackTransaction(); logger.error("Failed to initialize dual stack schema", error); throw new DatabaseError( "Schema initialization failed", ErrorCode.DB_SCHEMA_ERROR, { adapter: this.adapter.constructor.name }, error instanceof Error ? error : void 0 ); } } async executeSchemaQuery(sql) { logger.debug( "Using fallback schema creation - implement execute method in adapter" ); const rawDb = this.adapter instanceof SQLiteAdapter ? this.adapter.getRawDatabase() : null; if (rawDb) { try { rawDb.exec(sql); logger.debug("Executed schema query successfully"); } catch (error) { logger.error("Failed to execute schema query", { sql, error }); throw error; } } else { throw new DatabaseError( "Cannot execute schema query: raw database not available", ErrorCode.DB_CONNECTION_FAILED, { operation: "executeSchemaQuery" } ); } } getDefaultIndividualPermissions() { return { canRead: true, canWrite: true, canHandoff: true, canMerge: true, canAdminister: true }; } getSharedStackPermissions(role) { const basePermissions = { canRead: true, canWrite: true, canHandoff: true, canMerge: false, canAdminister: false }; switch (role) { case "lead": return { ...basePermissions, canMerge: true }; case "admin": return { ...basePermissions, canMerge: true, canAdminister: true }; default: return basePermissions; } } /** * Switch between individual and shared stacks */ async switchToStack(stackId) { const input = validateInput(SwitchStackSchema, { stackId }); try { if (input.stackId.startsWith("individual-")) { this.activeContext = { ...this.activeContext, stackId: input.stackId, type: "individual" }; return; } const stackContext = await this.loadStackContext(input.stackId); if (!stackContext) { throw new ValidationError( `Stack context not found: ${input.stackId}`, ErrorCode.STACK_CONTEXT_NOT_FOUND ); } await this.permissionManager.enforcePermission( this.permissionManager.createContext( this.activeContext.ownerId || "unknown", "read", "stack", input.stackId, stackContext ) ); this.activeContext = stackContext; if (!this.sharedStacks.has(input.stackId)) { const rawDb = this.adapter instanceof SQLiteAdapter ? this.adapter.getRawDatabase() : null; if (!rawDb) { throw new DatabaseError( "Failed to get raw database for shared stack", ErrorCode.DB_CONNECTION_FAILED, { stackId: input.stackId, operation: "switchToStack" } ); } const sharedStack = new FrameManager( rawDb, stackContext.projectId, input.stackId ); this.sharedStacks.set(input.stackId, sharedStack); } await this.updateStackActivity(input.stackId); logger.info(`Switched to stack: ${input.stackId}`, { type: stackContext.type }); } catch (error) { throw new ValidationError( `Failed to switch to stack: ${input.stackId}`, ErrorCode.OPERATION_FAILED, { stackId: input.stackId }, error instanceof Error ? error : void 0 ); } } /** * Get the current active stack manager */ getActiveStack() { if (this.activeContext.type === "individual") { return this.individualStack; } const sharedStack = this.sharedStacks.get(this.activeContext.stackId); if (!sharedStack) { throw new DatabaseError( `Active shared stack not initialized: ${this.activeContext.stackId}`, ErrorCode.INVALID_STATE ); } return sharedStack; } /** * Create a new shared stack for team collaboration */ async createSharedStack(teamId, name, ownerId, permissions) { const input = validateInput(CreateSharedStackSchema, { teamId, name, ownerId, permissions }); await this.permissionManager.enforcePermission( this.permissionManager.createContext( input.ownerId, "administer", "stack", `shared-${input.teamId}`, this.activeContext ) ); const stackId = `shared-${input.teamId}-${Date.now()}`; const stackContext = { stackId, type: "shared", projectId: this.activeContext.projectId, teamId: input.teamId, permissions: input.permissions || this.getSharedStackPermissions("admin"), metadata: { name: input.name, ownerId: input.ownerId }, createdAt: /* @__PURE__ */ new Date(), lastActive: /* @__PURE__ */ new Date() }; try { await this.saveStackContext(stackContext); const rawDb = this.adapter instanceof SQLiteAdapter ? this.adapter.getRawDatabase() : null; if (!rawDb) { throw new DatabaseError( "Failed to get raw database for new shared stack", ErrorCode.DB_CONNECTION_FAILED, { teamId, operation: "createSharedStack" } ); } const sharedStack = new FrameManager( rawDb, stackContext.projectId, stackId ); this.sharedStacks.set(stackId, sharedStack); const stackPermissions = stackContext.permissions; this.permissionManager.setStackPermissions( input.ownerId, stackId, stackPermissions ); logger.info(`Created shared stack: ${stackId}`, { teamId, name }); return stackId; } catch (error) { throw new DatabaseError( `Failed to create shared stack`, ErrorCode.OPERATION_FAILED, { teamId, name }, error instanceof Error ? error : void 0 ); } } /** * Initiate handoff of frames between stacks */ async initiateHandoff(targetStackId, frameIds, targetUserId, message) { await this.permissionManager.enforcePermission( this.permissionManager.createContext( this.activeContext.ownerId || "unknown", "handoff", "stack", this.activeContext.stackId, this.activeContext ) ); const requestId = `handoff-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; const request = { requestId, sourceStackId: this.activeContext.stackId, targetStackId, frameIds, requesterId: this.activeContext.ownerId, targetUserId, message, status: "pending", createdAt: /* @__PURE__ */ new Date(), expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1e3) // 24 hours }; try { await this.saveHandoffRequest(request); this.handoffRequests.set(requestId, request); logger.info(`Initiated handoff request: ${requestId}`, { sourceStack: this.activeContext.stackId, targetStack: targetStackId, frameCount: frameIds.length }); return requestId; } catch (error) { throw new DatabaseError( `Failed to initiate handoff`, ErrorCode.OPERATION_FAILED, { targetStackId, frameIds }, error instanceof Error ? error : void 0 ); } } /** * Accept a handoff request and move frames */ async acceptHandoff(requestId) { logger.debug("acceptHandoff called", { requestId }); const request = await this.loadHandoffRequest(requestId); logger.debug("loadHandoffRequest returned", { requestId, found: !!request }); if (!request) { logger.error("Handoff request not found", { requestId, availableRequests: Array.from(this.handoffRequests.keys()) }); throw new DatabaseError( `Handoff request not found: ${requestId}`, ErrorCode.RESOURCE_NOT_FOUND ); } if (request.status !== "pending") { throw new DatabaseError( `Handoff request is not pending: ${request.status}`, ErrorCode.INVALID_STATE ); } if (request.expiresAt < /* @__PURE__ */ new Date()) { throw new DatabaseError( `Handoff request has expired`, ErrorCode.OPERATION_EXPIRED ); } try { logger.debug("Starting moveFramesBetweenStacks", { requestId }); const syncResult = await this.moveFramesBetweenStacks( request.sourceStackId, request.targetStackId, request.frameIds ); logger.debug("moveFramesBetweenStacks completed", { requestId, success: syncResult.success }); logger.debug("Updating request status", { requestId }); request.status = "accepted"; logger.debug("Calling saveHandoffRequest", { requestId }); await this.saveHandoffRequest(request); logger.debug("saveHandoffRequest completed", { requestId }); logger.info(`Accepted handoff request: ${requestId}`, { frameCount: request.frameIds.length, conflicts: syncResult.conflictFrames.length }); return syncResult; } catch (error) { logger.error("acceptHandoff caught error", { error: error instanceof Error ? error.message : error }); request.status = "rejected"; await this.saveHandoffRequest(request); throw new DatabaseError( `Failed to accept handoff`, ErrorCode.OPERATION_FAILED, { requestId }, error instanceof Error ? error : void 0 ); } } /** * Sync frames between individual and shared stacks */ async syncStacks(sourceStackId, targetStackId, options) { try { const sourceStack = this.getStackManager(sourceStackId); const targetStack = this.getStackManager(targetStackId); const framesToSync = options.frameIds || (await sourceStack.getActiveFrames()).map((f) => f.frame_id); const result = { success: true, conflictFrames: [], mergedFrames: [], errors: [] }; for (const frameId of framesToSync) { try { const sourceFrame = await sourceStack.getFrame(frameId); if (!sourceFrame) { result.errors.push({ frameId, error: "Source frame not found", resolution: "skipped" }); continue; } const existingFrame = await targetStack.getFrame(frameId); if (existingFrame) { switch (options.conflictResolution) { case "skip": result.conflictFrames.push(frameId); result.errors.push({ frameId, error: "Frame already exists", resolution: "skipped" }); continue; case "merge": if (!options.dryRun) { await this.mergeFrames( existingFrame, sourceFrame, targetStack ); } result.mergedFrames.push(frameId); break; case "overwrite": if (!options.dryRun) { await targetStack.deleteFrame(frameId); await this.copyFrame(sourceFrame, targetStack); } result.mergedFrames.push(frameId); break; } } else { if (!options.dryRun) { await this.copyFrame(sourceFrame, targetStack); } result.mergedFrames.push(frameId); } } catch (error) { result.errors.push({ frameId, error: error instanceof Error ? error.message : String(error), resolution: "skipped" }); result.success = false; } } logger.info(`Stack sync completed`, { source: sourceStackId, target: targetStackId, merged: result.mergedFrames.length, conflicts: result.conflictFrames.length, errors: result.errors.length }); return result; } catch (error) { throw new DatabaseError( `Stack sync failed`, ErrorCode.OPERATION_FAILED, { sourceStackId, targetStackId }, error instanceof Error ? error : void 0 ); } } getStackManager(stackId) { logger.debug("getStackManager called", { stackId, availableStacks: Array.from(this.sharedStacks.keys()) }); if (stackId.startsWith("individual-")) { logger.debug("Returning individual stack", { stackId }); return this.individualStack; } const sharedStack = this.sharedStacks.get(stackId); if (!sharedStack) { logger.error("Stack manager not found", { stackId, availableSharedStacks: Array.from(this.sharedStacks.keys()), message: "getStackManager could not find shared stack" }); throw new DatabaseError( `Stack manager not found: ${stackId}`, ErrorCode.RESOURCE_NOT_FOUND ); } logger.debug("Returning shared stack", { stackId }); return sharedStack; } async moveFramesBetweenStacks(sourceStackId, targetStackId, frameIds) { const syncResult = await this.syncStacks(sourceStackId, targetStackId, { frameIds, conflictResolution: "merge" }); if (syncResult.success && syncResult.errors.length === 0) { const sourceStack = this.getStackManager(sourceStackId); for (const frameId of frameIds) { try { sourceStack.deleteFrame(frameId); logger.debug("Deleted frame from source stack", { frameId, sourceStackId }); } catch (error) { logger.warn("Failed to delete frame from source stack", { frameId, error }); } } logger.debug("Completed frame cleanup from source stack", { frameIds: frameIds.length }); } return syncResult; } async copyFrame(frame, targetStack) { await targetStack.createFrame({ type: frame.type, name: frame.name, inputs: frame.inputs }); const events = await this.individualStack.getFrameEvents(frame.frame_id); for (const event of events) { await targetStack.addEvent(frame.frame_id, { type: event.type, text: event.text, metadata: event.metadata }); } const anchors = await this.individualStack.getFrameAnchors(frame.frame_id); for (const anchor of anchors) { await targetStack.addAnchor(frame.frame_id, { type: anchor.type, text: anchor.text, priority: anchor.priority, metadata: anchor.metadata }); } } async mergeFrames(existingFrame, sourceFrame, targetStack) { const sourceEvents = await this.individualStack.getFrameEvents( sourceFrame.frame_id ); for (const event of sourceEvents) { await targetStack.addEvent(existingFrame.frame_id, { type: event.type, text: event.text, metadata: { ...event.metadata, merged: true } }); } const sourceAnchors = await this.individualStack.getFrameAnchors( sourceFrame.frame_id ); for (const anchor of sourceAnchors) { await targetStack.addAnchor(existingFrame.frame_id, { type: anchor.type, text: anchor.text, priority: anchor.priority, metadata: { ...anchor.metadata, merged: true } }); } } async loadStackContext(stackId) { try { const rawDb = this.adapter instanceof SQLiteAdapter ? this.adapter.getRawDatabase() : null; if (!rawDb) { return null; } const query = rawDb.prepare(` SELECT stack_id, type, project_id, owner_id, team_id, permissions, metadata, created_at, last_active FROM stack_contexts WHERE stack_id = ? `); const row = query.get(stackId); if (!row) { return null; } return { stackId: row.stack_id, type: row.type, projectId: row.project_id, ownerId: row.owner_id, teamId: row.team_id, permissions: JSON.parse(row.permissions), metadata: JSON.parse(row.metadata || "{}"), createdAt: new Date(row.created_at), lastActive: new Date(row.last_active) }; } catch (error) { logger.error("Failed to load stack context", { stackId, error }); return null; } } async saveStackContext(context) { try { const rawDb = this.adapter instanceof SQLiteAdapter ? this.adapter.getRawDatabase() : null; if (!rawDb) { throw new DatabaseError( "SQLite database not available for stack context save", ErrorCode.DB_CONNECTION_FAILED, { stackId: context.stackId, operation: "saveStackContext" } ); } const query = rawDb.prepare(` INSERT OR REPLACE INTO stack_contexts (stack_id, type, project_id, owner_id, team_id, permissions, metadata, created_at, last_active) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `); query.run( context.stackId, context.type, context.projectId, context.ownerId || null, context.teamId || null, JSON.stringify(context.permissions), JSON.stringify(context.metadata || {}), context.createdAt.getTime(), context.lastActive.getTime() ); logger.debug("Saved stack context", { stackId: context.stackId }); } catch (error) { logger.error("Failed to save stack context", { stackId: context.stackId, error }); throw error; } } async updateStackActivity(stackId) { try { const rawDb = this.adapter instanceof SQLiteAdapter ? this.adapter.getRawDatabase() : null; if (!rawDb) { logger.warn("SQLite database not available for activity update"); return; } const query = rawDb.prepare(` UPDATE stack_contexts SET last_active = ? WHERE stack_id = ? `); query.run(Date.now(), stackId); logger.debug("Updated stack activity", { stackId }); } catch (error) { logger.error("Failed to update stack activity", { stackId, error }); } } async loadHandoffRequest(requestId) { const memoryRequest = this.handoffRequests.get(requestId); if (memoryRequest) { return memoryRequest; } try { const rawDb = this.adapter instanceof SQLiteAdapter ? this.adapter.getRawDatabase() : null; if (rawDb) { const query = rawDb.prepare(` SELECT * FROM handoff_requests WHERE request_id = ? `); const row = query.get(requestId); if (row) { const request = { requestId: row.request_id, sourceStackId: row.source_stack_id, targetStackId: row.target_stack_id, frameIds: JSON.parse(row.frame_ids), status: row.status, createdAt: new Date(row.created_at), expiresAt: new Date(row.expires_at), targetUserId: row.target_user_id, message: row.message }; this.handoffRequests.set(requestId, request); return request; } } } catch (error) { logger.error("Failed to load handoff request from database", { requestId, error }); } return null; } async saveHandoffRequest(request) { try { const rawDb = this.adapter instanceof SQLiteAdapter ? this.adapter.getRawDatabase() : null; if (rawDb) { const query = rawDb.prepare(` INSERT OR REPLACE INTO handoff_requests (request_id, source_stack_id, target_stack_id, frame_ids, status, created_at, expires_at, target_user_id, message) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `); query.run( request.requestId, request.sourceStackId, request.targetStackId, JSON.stringify(request.frameIds), request.status, request.createdAt.getTime(), request.expiresAt.getTime(), request.targetUserId || null, request.message || null ); logger.debug("Saved handoff request to database", { requestId: request.requestId }); } this.handoffRequests.set(request.requestId, request); } catch (error) { logger.error("Failed to save handoff request", { requestId: request.requestId, error }); this.handoffRequests.set(request.requestId, request); } } /** * Get list of available stacks for the current user */ async getAvailableStacks() { try { const stacks = []; stacks.push(this.activeContext); const rawDb = this.adapter instanceof SQLiteAdapter ? this.adapter.getRawDatabase() : null; if (rawDb) { const query = rawDb.prepare(` SELECT stack_id, type, project_id, owner_id, team_id, permissions, metadata, created_at, last_active FROM stack_contexts WHERE type = 'shared' AND project_id = ? `); const rows = query.all(this.activeContext.projectId); for (const row of rows) { const context = { stackId: row.stack_id, type: row.type, projectId: row.project_id, ownerId: row.owner_id, teamId: row.team_id, permissions: JSON.parse(row.permissions), metadata: JSON.parse(row.metadata || "{}"), createdAt: new Date(row.created_at), lastActive: new Date(row.last_active) }; try { await this.permissionManager.enforcePermission( this.permissionManager.createContext( this.activeContext.ownerId || "unknown", "read", "stack", context.stackId, context ) ); stacks.push(context); } catch { logger.debug("User lacks access to stack", { stackId: context.stackId, userId: this.activeContext.ownerId }); } } } return stacks; } catch (error) { logger.error("Failed to get available stacks", error); return [this.activeContext]; } } /** * Get pending handoff requests for the current user */ async getPendingHandoffRequests() { return Array.from(this.handoffRequests.values()).filter( (request) => request.status === "pending" && request.expiresAt > /* @__PURE__ */ new Date() ); } /** * Get current stack context */ getCurrentContext() { return { ...this.activeContext }; } /** * Get permission manager for external access */ getPermissionManager() { return this.permissionManager; } /** * Add user to shared stack with specific permissions */ async addUserToSharedStack(stackId, userId, permissions, requesterId) { await this.permissionManager.enforcePermission( this.permissionManager.createContext( requesterId, "administer", "stack", stackId ) ); this.permissionManager.setStackPermissions(userId, stackId, permissions); logger.info(`Added user to shared stack`, { stackId, userId, permissions, requesterId }); } /** * Remove user from shared stack */ async removeUserFromSharedStack(stackId, userId, requesterId) { await this.permissionManager.enforcePermission( this.permissionManager.createContext( requesterId, "administer", "stack", stackId ) ); const userPermissions = this.permissionManager.getUserPermissions(userId); userPermissions.delete(stackId); logger.info(`Removed user from shared stack`, { stackId, userId, requesterId }); } } export { DualStackManager };