@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
JavaScript
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