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