UNPKG

@stackmemoryai/stackmemory

Version:

Project-scoped memory for AI coding tools. Durable context across sessions with MCP integration, frames, smart retrieval, Claude Code skills, and automatic hooks.

507 lines (506 loc) 14.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 { logger } from "../monitoring/logger.js"; import { IntegrationError, ErrorCode } from "../errors/index.js"; class SimpleEventBus { handlers = /* @__PURE__ */ new Map(); emit(event, data) { const eventHandlers = this.handlers.get(event); if (!eventHandlers) return; for (const handler of eventHandlers) { try { const result = handler(data); if (result instanceof Promise) { result.catch((err) => { logger.error(`Event handler error for ${String(event)}:`, err); }); } } catch (err) { logger.error(`Event handler error for ${String(event)}:`, err); } } } on(event, handler) { const key = event; if (!this.handlers.has(key)) { this.handlers.set(key, /* @__PURE__ */ new Set()); } this.handlers.get(key).add(handler); return () => { this.handlers.get(key)?.delete(handler); }; } once(event, handler) { const wrappedHandler = (data) => { unsubscribe(); return handler(data); }; const unsubscribe = this.on(event, wrappedHandler); return unsubscribe; } off(event) { this.handlers.delete(event); } /** Clear all event handlers */ clear() { this.handlers.clear(); } } class PluginRegistry { plugins = /* @__PURE__ */ new Map(); eventBus; projectRoot; frameAccess; storageProvider; constructor(options) { this.projectRoot = options.projectRoot; this.frameAccess = options.frameAccess; this.storageProvider = options.storageProvider; this.eventBus = options.eventBus || new SimpleEventBus(); } /** * Get the event bus */ getEventBus() { return this.eventBus; } /** * Register and load a plugin */ async load(plugin, config) { if (this.plugins.has(plugin.id)) { throw new IntegrationError( `Plugin ${plugin.id} is already loaded`, ErrorCode.MCP_EXECUTION_FAILED, { pluginId: plugin.id } ); } const context = this.createPluginContext(plugin.id, config || {}); const registration = { plugin, state: "loading", context }; this.plugins.set(plugin.id, registration); try { await plugin.init(context); registration.state = "active"; registration.loadedAt = /* @__PURE__ */ new Date(); if (plugin.watch) { registration.watchUnsubscribe = plugin.watch((frame) => { this.eventBus.emit("context:updated", { pluginId: plugin.id, frame }); }); } this.eventBus.emit("plugin:loaded", { pluginId: plugin.id }); logger.info(`Plugin loaded: ${plugin.id} v${plugin.version}`); } catch (error) { registration.state = "error"; registration.error = error instanceof Error ? error : new Error(String(error)); this.eventBus.emit("plugin:error", { pluginId: plugin.id, error: registration.error }); throw new IntegrationError( `Failed to load plugin ${plugin.id}: ${registration.error.message}`, ErrorCode.MCP_EXECUTION_FAILED, { pluginId: plugin.id }, registration.error ); } } /** * Unload a plugin */ async unload(pluginId) { const registration = this.plugins.get(pluginId); if (!registration) { throw new IntegrationError( `Plugin ${pluginId} is not loaded`, ErrorCode.MCP_TOOL_NOT_FOUND, { pluginId } ); } registration.state = "unloading"; try { if (registration.watchUnsubscribe) { registration.watchUnsubscribe(); } await registration.plugin.destroy(); this.plugins.delete(pluginId); this.eventBus.emit("plugin:unloaded", { pluginId }); logger.info(`Plugin unloaded: ${pluginId}`); } catch (error) { registration.state = "error"; registration.error = error instanceof Error ? error : new Error(String(error)); throw error; } } /** * Get a loaded plugin */ get(pluginId) { return this.plugins.get(pluginId)?.plugin; } /** * Get plugin registration */ getRegistration(pluginId) { return this.plugins.get(pluginId); } /** * Get all loaded plugins */ getAll() { return Array.from(this.plugins.values()).filter((r) => r.state === "active").map((r) => r.plugin); } /** * Get plugins by service type */ getByService(service) { return this.getAll().filter((p) => p.service === service); } /** * Sync all active plugins */ async syncAll() { const results = /* @__PURE__ */ new Map(); for (const [pluginId, registration] of this.plugins) { if (registration.state !== "active") continue; try { const frames = await registration.plugin.sync(); results.set(pluginId, frames); this.eventBus.emit("context:synced", { pluginId, frames }); } catch (error) { logger.error(`Sync failed for plugin ${pluginId}:`, error); registration.error = error instanceof Error ? error : new Error(String(error)); } } return results; } /** * Execute a plugin action */ async executeAction(pluginId, action, params) { const registration = this.plugins.get(pluginId); if (!registration) { throw new IntegrationError( `Plugin ${pluginId} is not loaded`, ErrorCode.MCP_TOOL_NOT_FOUND, { pluginId } ); } if (registration.state !== "active") { throw new IntegrationError( `Plugin ${pluginId} is not active (state: ${registration.state})`, ErrorCode.MCP_EXECUTION_FAILED, { pluginId, state: registration.state } ); } const handler = registration.plugin.actions?.[action]; if (!handler) { throw new IntegrationError( `Action ${action} not found in plugin ${pluginId}`, ErrorCode.MCP_TOOL_NOT_FOUND, { pluginId, action } ); } return handler(params, registration.context); } /** * List all available actions across plugins */ listActions() { const actions = []; for (const [pluginId, registration] of this.plugins) { if (registration.state !== "active") continue; const plugin = registration.plugin; if (plugin.actions) { for (const action of Object.keys(plugin.actions)) { actions.push({ pluginId, action, service: plugin.service }); } } } return actions; } /** * Unload all plugins */ async unloadAll() { const pluginIds = Array.from(this.plugins.keys()); for (const pluginId of pluginIds) { try { await this.unload(pluginId); } catch (error) { logger.error(`Failed to unload plugin ${pluginId}:`, error); } } this.eventBus.clear(); } /** * Create plugin context */ createPluginContext(pluginId, config) { return { projectRoot: this.projectRoot, frames: this.frameAccess, storage: this.storageProvider(pluginId), events: this.eventBus, logger: this.createPluginLogger(pluginId), config }; } /** * Create scoped logger for plugin */ createPluginLogger(pluginId) { const prefix = `[plugin:${pluginId}]`; return { debug: (message, context) => logger.debug(`${prefix} ${message}`, context), info: (message, context) => logger.info(`${prefix} ${message}`, context), warn: (message, context) => logger.warn(`${prefix} ${message}`, context), error: (message, errorOrContext) => logger.error(`${prefix} ${message}`, errorOrContext) }; } } function createLinearPluginConfig() { return { direction: "bidirectional", conflictResolution: "newest_wins", syncInterval: 15, webhookEnabled: false }; } function createLinearPlugin() { let context; let syncIntervalId; return { id: "linear", name: "Linear Integration", version: "1.0.0", service: "linear", permissions: [ "frames:read", "frames:write", "network:api.linear.app", "storage:local" ], configSchema: { type: "object", properties: { teamId: { type: "string", description: "Linear team ID" }, direction: { type: "string", enum: ["bidirectional", "to_linear", "from_linear"], default: "bidirectional" }, conflictResolution: { type: "string", enum: ["linear_wins", "stackmemory_wins", "newest_wins", "manual"], default: "newest_wins" }, syncInterval: { type: "number", description: "Sync interval in minutes (0 to disable)", default: 15 }, webhookEnabled: { type: "boolean", default: false }, webhookSecret: { type: "string" } } }, async init(ctx) { context = ctx; const apiKey = process.env["LINEAR_API_KEY"]; if (!apiKey) { throw new IntegrationError( "LINEAR_API_KEY environment variable not set", ErrorCode.LINEAR_AUTH_FAILED ); } ctx.logger.info("Linear plugin initialized"); const interval = ctx.config["syncInterval"] || 0; if (interval > 0) { syncIntervalId = setInterval( () => { this.sync().catch((err) => { ctx.logger.error("Auto-sync failed", err); }); }, interval * 60 * 1e3 ); ctx.logger.info(`Auto-sync enabled: every ${interval} minutes`); } }, async destroy() { if (syncIntervalId) { clearInterval(syncIntervalId); syncIntervalId = void 0; } context?.logger.info("Linear plugin destroyed"); context = void 0; }, async sync() { if (!context) { throw new IntegrationError( "Plugin not initialized", ErrorCode.LINEAR_SYNC_FAILED ); } context.logger.info("Starting Linear sync"); const frames = []; context.logger.info(`Sync complete: ${frames.length} frames`); return frames; }, watch(callback) { context?.logger.info("Watch mode enabled"); return () => { context?.logger.info("Watch mode disabled"); }; }, actions: { createIssue: async (params, ctx) => { ctx.logger.info("Creating Linear issue", { title: params.title }); throw new IntegrationError( "Not implemented - integrate with LinearClient", ErrorCode.LINEAR_API_ERROR ); }, updateIssue: async (params, ctx) => { ctx.logger.info("Updating Linear issue", { issueId: params.issueId }); throw new IntegrationError( "Not implemented - integrate with LinearClient", ErrorCode.LINEAR_API_ERROR ); }, transitionIssue: async (params, ctx) => { ctx.logger.info("Transitioning Linear issue", params); throw new IntegrationError( "Not implemented - integrate with LinearClient", ErrorCode.LINEAR_API_ERROR ); }, addComment: async (params, ctx) => { ctx.logger.info("Adding comment to Linear issue", { issueId: params.issueId }); throw new IntegrationError( "Not implemented - integrate with LinearClient", ErrorCode.LINEAR_API_ERROR ); }, getIssue: async (params, ctx) => { ctx.logger.info("Getting Linear issue", { issueId: params.issueId }); throw new IntegrationError( "Not implemented - integrate with LinearClient", ErrorCode.LINEAR_API_ERROR ); }, importIssues: async (params, ctx) => { ctx.logger.info("Importing issues from Linear", params); throw new IntegrationError( "Not implemented - integrate with LinearSyncEngine", ErrorCode.LINEAR_API_ERROR ); } } }; } class InMemoryPluginStorage { data = /* @__PURE__ */ new Map(); prefix; constructor(pluginId) { this.prefix = `plugin:${pluginId}:`; } async get(key) { return this.data.get(this.prefix + key); } async set(key, value) { this.data.set(this.prefix + key, value); } async delete(key) { this.data.delete(this.prefix + key); } async keys(prefix) { const fullPrefix = this.prefix + (prefix || ""); const keys = []; for (const key of this.data.keys()) { if (key.startsWith(fullPrefix)) { keys.push(key.slice(this.prefix.length)); } } return keys; } } function createPluginRegistry(options) { return new PluginRegistry({ projectRoot: options.projectRoot, frameAccess: options.frameAccess, storageProvider: options.storageProvider || ((pluginId) => new InMemoryPluginStorage(pluginId)) }); } function createMockFrameAccess() { const frames = /* @__PURE__ */ new Map(); return { async getActive() { for (const frame of frames.values()) { if (frame.state === "active") return frame; } return void 0; }, async get(frameId) { return frames.get(frameId); }, async getContext(_frameId) { return void 0; }, async create(options) { const frame = { frame_id: `frame-${Date.now()}-${Math.random().toString(36).slice(2)}`, run_id: "mock-run", project_id: "mock-project", parent_frame_id: options.parentFrameId, depth: options.parentFrameId ? 1 : 0, type: options.type, name: options.name, state: "active", inputs: options.inputs || {}, outputs: {}, digest_json: {}, created_at: Date.now() }; frames.set(frame.frame_id, frame); return frame; }, async close(frameId, outputs) { const frame = frames.get(frameId); if (frame) { frame.state = "closed"; frame.outputs = outputs || {}; frame.closed_at = Date.now(); } } }; } export { SimpleEventBus as EventBusImpl, InMemoryPluginStorage, PluginRegistry, PluginRegistry as Registry, SimpleEventBus, createLinearPlugin, createLinearPluginConfig, createMockFrameAccess, createPluginRegistry }; //# sourceMappingURL=plugin-system.js.map