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.

568 lines (567 loc) 17.1 kB
import { fileURLToPath as __fileURLToPath } from 'url'; import { dirname as __pathDirname } from 'path'; const __filename = __fileURLToPath(import.meta.url); const __dirname = __pathDirname(__filename); import { z } from "zod"; import { v4 as uuidv4 } from "uuid"; import { logger } from "../monitoring/logger.js"; import { ValidationError, ErrorCode, StackMemoryError, SystemError } from "../errors/index.js"; const ToolNameSchema = z.string().min(3, "Tool name must be at least 3 characters").max(64, "Tool name must be at most 64 characters").regex( /^[a-z][a-z0-9_]*$/, "Tool name must start with lowercase letter and contain only lowercase letters, numbers, and underscores" ); const JSONSchemaPropertySchema = z.lazy( () => z.object({ type: z.enum(["string", "number", "boolean", "array", "object", "null"]), description: z.string().optional(), enum: z.array(z.union([z.string(), z.number(), z.boolean()])).optional(), default: z.unknown().optional(), minimum: z.number().optional(), maximum: z.number().optional(), minLength: z.number().optional(), maxLength: z.number().optional(), pattern: z.string().optional(), items: JSONSchemaPropertySchema.optional(), properties: z.record(JSONSchemaPropertySchema).optional(), required: z.array(z.string()).optional(), format: z.string().optional() }) ); const JSONSchemaSchema = z.object({ type: z.literal("object"), properties: z.record(JSONSchemaPropertySchema).optional(), required: z.array(z.string()).optional(), additionalProperties: z.boolean().optional(), description: z.string().optional(), default: z.unknown().optional() }); const ToolMetadataSchema = z.object({ version: z.string().optional(), author: z.string().optional(), category: z.string().optional(), tags: z.array(z.string()).optional(), timeout: z.number().min(100).max(3e5).optional(), permissions: z.array(z.string()).optional() }).optional(); class ToolRegistry { tools = /* @__PURE__ */ new Map(); eventListeners = /* @__PURE__ */ new Map(); extensionStorage = /* @__PURE__ */ new Map(); defaultTimeout = 3e4; // 30 seconds constructor(options) { if (options?.defaultTimeout) { this.defaultTimeout = options.defaultTimeout; } logger.info("ToolRegistry initialized"); } // ============================================ // Registration // ============================================ /** * Register a new custom tool * @throws ValidationError if tool definition is invalid * @throws StackMemoryError if tool with same name already exists */ register(definition) { this.validateToolDefinition(definition); if (this.tools.has(definition.name)) { throw new StackMemoryError({ code: ErrorCode.VALIDATION_FAILED, message: `Tool '${definition.name}' is already registered`, context: { toolName: definition.name } }); } const toolId = uuidv4(); const registeredTool = { ...definition, id: toolId, registeredAt: /* @__PURE__ */ new Date(), usageCount: 0, enabled: true }; this.tools.set(definition.name, registeredTool); logger.info("Tool registered", { toolId, name: definition.name, category: definition.metadata?.category }); return toolId; } /** * Unregister a tool by name */ unregister(name) { const tool = this.tools.get(name); if (!tool) { return false; } this.tools.delete(name); this.extensionStorage.delete(name); logger.info("Tool unregistered", { name, toolId: tool.id }); return true; } /** * Update an existing tool definition */ update(name, updates) { const tool = this.tools.get(name); if (!tool) { return false; } if (updates.parameters) { const result = JSONSchemaSchema.safeParse(updates.parameters); if (!result.success) { throw new ValidationError( `Invalid parameters schema: ${result.error.message}`, ErrorCode.VALIDATION_FAILED, { errors: result.error.errors } ); } } Object.assign(tool, updates); logger.info("Tool updated", { name, toolId: tool.id }); return true; } // ============================================ // Discovery // ============================================ /** * Get a tool by name */ get(name) { return this.tools.get(name); } /** * List all registered tools */ list(filter) { let tools = Array.from(this.tools.values()); if (filter?.category) { tools = tools.filter((t) => t.metadata?.category === filter.category); } if (filter?.tags && filter.tags.length > 0) { tools = tools.filter( (t) => filter.tags.some((tag) => t.metadata?.tags?.includes(tag)) ); } if (filter?.enabled !== void 0) { tools = tools.filter((t) => t.enabled === filter.enabled); } return tools; } /** * Get tool definitions in MCP format */ getMCPToolDefinitions() { return Array.from(this.tools.values()).filter((t) => t.enabled).map((t) => ({ name: t.name, description: t.description, inputSchema: t.parameters })); } /** * Check if a tool exists */ has(name) { return this.tools.has(name); } /** * Get tool count */ get size() { return this.tools.size; } // ============================================ // Execution // ============================================ /** * Execute a tool with sandboxed context */ async execute(name, params, options) { const tool = this.tools.get(name); if (!tool) { return { success: false, error: `Tool '${name}' not found` }; } if (!tool.enabled) { return { success: false, error: `Tool '${name}' is disabled` }; } const validationResult = this.validateParams(params, tool.parameters); if (!validationResult.valid) { return { success: false, error: `Invalid parameters: ${validationResult.errors.join(", ")}` }; } const context = this.createSandboxedContext(name, options?.context); const timeout = options?.timeout ?? tool.metadata?.timeout ?? this.defaultTimeout; try { const result = await this.executeWithSandbox( () => tool.execute(params, context), timeout, options?.sandboxed ?? true ); tool.lastUsedAt = /* @__PURE__ */ new Date(); tool.usageCount++; logger.info("Tool executed successfully", { name, toolId: tool.id, duration: Date.now() - tool.lastUsedAt.getTime() }); return result; } catch (error) { const message = error instanceof Error ? error.message : String(error); logger.error("Tool execution failed", { name, toolId: tool.id, error: message }); return { success: false, error: message, metadata: { toolId: tool.id } }; } } // ============================================ // Private Methods // ============================================ /** * Validate a tool definition before registration */ validateToolDefinition(definition) { const nameResult = ToolNameSchema.safeParse(definition.name); if (!nameResult.success) { throw new ValidationError( `Invalid tool name: ${nameResult.error.message}`, ErrorCode.VALIDATION_FAILED, { name: definition.name, errors: nameResult.error.errors } ); } if (!definition.description || definition.description.length < 10) { throw new ValidationError( "Tool description must be at least 10 characters", ErrorCode.VALIDATION_FAILED, { description: definition.description } ); } if (definition.description.length > 500) { throw new ValidationError( "Tool description must be at most 500 characters", ErrorCode.VALIDATION_FAILED, { descriptionLength: definition.description.length } ); } const schemaResult = JSONSchemaSchema.safeParse(definition.parameters); if (!schemaResult.success) { throw new ValidationError( `Invalid parameters schema: ${schemaResult.error.message}`, ErrorCode.VALIDATION_FAILED, { errors: schemaResult.error.errors } ); } if (typeof definition.execute !== "function") { throw new ValidationError( "Tool execute must be a function", ErrorCode.VALIDATION_FAILED ); } if (definition.metadata) { const metaResult = ToolMetadataSchema.safeParse(definition.metadata); if (!metaResult.success) { throw new ValidationError( `Invalid tool metadata: ${metaResult.error.message}`, ErrorCode.VALIDATION_FAILED, { errors: metaResult.error.errors } ); } } } /** * Validate parameters against JSON Schema */ validateParams(params, schema) { const errors = []; if (schema.type !== "object") { errors.push('Parameters schema must have type "object"'); return { valid: false, errors }; } if (params === null || params === void 0) { if (schema.required && schema.required.length > 0) { errors.push( `Missing required parameters: ${schema.required.join(", ")}` ); } return { valid: errors.length === 0, errors }; } if (typeof params !== "object" || Array.isArray(params)) { errors.push("Parameters must be an object"); return { valid: false, errors }; } const paramObj = params; if (schema.required) { for (const field of schema.required) { if (!(field in paramObj)) { errors.push(`Missing required parameter: ${field}`); } } } if (schema.properties) { for (const [key, propSchema] of Object.entries(schema.properties)) { if (key in paramObj) { const propErrors = this.validateProperty( paramObj[key], propSchema, key ); errors.push(...propErrors); } } if (schema.additionalProperties === false) { for (const key of Object.keys(paramObj)) { if (!(key in schema.properties)) { errors.push(`Unknown parameter: ${key}`); } } } } return { valid: errors.length === 0, errors }; } /** * Validate a single property value */ validateProperty(value, schema, path) { const errors = []; const actualType = this.getJSONType(value); if (actualType !== schema.type && value !== void 0 && value !== null) { if (!(schema.type === "null" && value === null)) { errors.push( `Parameter '${path}' must be of type ${schema.type}, got ${actualType}` ); } } if (schema.type === "string" && typeof value === "string") { if (schema.minLength !== void 0 && value.length < schema.minLength) { errors.push( `Parameter '${path}' must be at least ${schema.minLength} characters` ); } if (schema.maxLength !== void 0 && value.length > schema.maxLength) { errors.push( `Parameter '${path}' must be at most ${schema.maxLength} characters` ); } if (schema.pattern && !new RegExp(schema.pattern).test(value)) { errors.push( `Parameter '${path}' does not match pattern ${schema.pattern}` ); } if (schema.enum && !schema.enum.includes(value)) { errors.push( `Parameter '${path}' must be one of: ${schema.enum.join(", ")}` ); } } if (schema.type === "number" && typeof value === "number") { if (schema.minimum !== void 0 && value < schema.minimum) { errors.push(`Parameter '${path}' must be at least ${schema.minimum}`); } if (schema.maximum !== void 0 && value > schema.maximum) { errors.push(`Parameter '${path}' must be at most ${schema.maximum}`); } if (schema.enum && !schema.enum.includes(value)) { errors.push( `Parameter '${path}' must be one of: ${schema.enum.join(", ")}` ); } } if (schema.type === "array" && Array.isArray(value) && schema.items) { value.forEach((item, index) => { const itemErrors = this.validateProperty( item, schema.items, `${path}[${index}]` ); errors.push(...itemErrors); }); } return errors; } /** * Get JSON type of a value */ getJSONType(value) { if (value === null) return "null"; if (Array.isArray(value)) return "array"; return typeof value; } /** * Create a sandboxed extension context */ createSandboxedContext(toolName, overrides) { if (!this.extensionStorage.has(toolName)) { this.extensionStorage.set(toolName, /* @__PURE__ */ new Map()); } const storage = this.extensionStorage.get(toolName); const context = { projectId: overrides?.projectId ?? "sandbox", sessionId: overrides?.sessionId ?? uuidv4(), runId: overrides?.runId ?? uuidv4(), // Sandboxed fetch (could add URL allowlist here) fetch: globalThis.fetch, // Extension storage storage: { async get(key) { return storage.get(key); }, async set(key, value) { storage.set(key, value); }, async delete(key) { storage.delete(key); }, async clear() { storage.clear(); } }, // Event communication emit: (event, data) => { const listeners = this.eventListeners.get(event); if (listeners) { listeners.forEach((handler) => { try { handler(data); } catch (error) { logger.error("Event handler error", { event, error: error instanceof Error ? error.message : String(error) }); } }); } }, on: (event, handler) => { if (!this.eventListeners.has(event)) { this.eventListeners.set(event, /* @__PURE__ */ new Set()); } this.eventListeners.get(event).add(handler); return () => { this.eventListeners.get(event)?.delete(handler); }; }, // Sandboxed logging log: { info: (message, meta) => { logger.info(`[Tool:${toolName}] ${message}`, meta); }, warn: (message, meta) => { logger.warn(`[Tool:${toolName}] ${message}`, meta); }, error: (message, meta) => { logger.error(`[Tool:${toolName}] ${message}`, meta); } } }; return context; } /** * Execute tool with sandbox wrapper and timeout */ async executeWithSandbox(executor, timeout, sandboxed) { const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { reject( new SystemError( `Tool execution timed out after ${timeout}ms`, ErrorCode.OPERATION_TIMEOUT ) ); }, timeout); }); const executionPromise = (async () => { try { if (sandboxed) { return await Promise.resolve(executor()); } else { return await Promise.resolve(executor()); } } catch (error) { if (error instanceof StackMemoryError) { throw error; } throw new SystemError( `Tool execution error: ${error instanceof Error ? error.message : String(error)}`, ErrorCode.MCP_EXECUTION_FAILED, { error: String(error) } ); } })(); return Promise.race([executionPromise, timeoutPromise]); } /** * Clear all event listeners */ clearEventListeners() { this.eventListeners.clear(); } /** * Get statistics about registered tools */ getStats() { const tools = Array.from(this.tools.values()); const byCategory = {}; for (const tool of tools) { const category = tool.metadata?.category ?? "uncategorized"; byCategory[category] = (byCategory[category] ?? 0) + 1; } return { totalTools: tools.length, enabledTools: tools.filter((t) => t.enabled).length, totalExecutions: tools.reduce((sum, t) => sum + t.usageCount, 0), byCategory }; } /** * Enable or disable a tool */ setEnabled(name, enabled) { const tool = this.tools.get(name); if (!tool) { return false; } tool.enabled = enabled; logger.info(`Tool ${enabled ? "enabled" : "disabled"}`, { name }); return true; } } function createToolRegistry(options) { return new ToolRegistry(options); } function defineTool(name, description, parameters, execute, metadata) { return { name, description, parameters, execute, metadata }; } var custom_tools_default = ToolRegistry; export { ToolRegistry, createToolRegistry, custom_tools_default as default, defineTool }; //# sourceMappingURL=custom-tools.js.map